feat(preview): Implement object preview functionality (#94)

* feat(preview): implement object preview functionality with token-based access

* docs: update README to include inline object preview feature

* test: add range read error handling and attachment content disposition tests
This commit is contained in:
Noste
2026-07-15 00:01:04 +02:00
committed by GitHub
parent 5d33382e30
commit c0976a1e1d
39 changed files with 3867 additions and 28 deletions
+2
View File
@@ -66,5 +66,7 @@ config.yaml
docs/**/*.md docs/**/*.md
!docs/garage-setup.md !docs/garage-setup.md
!docs/access-control.md !docs/access-control.md
**/state**
**/content**
**/worktrees **/worktrees
+2 -1
View File
@@ -32,6 +32,7 @@ A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garag
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.) - **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
- **Multi-user access control** - optional OIDC-team-based permissions, see [docs/access-control.md](docs/access-control.md) - **Multi-user access control** - optional OIDC-team-based permissions, see [docs/access-control.md](docs/access-control.md)
- **Easy deployment** - single Docker image or Helm chart, configure with one YAML file - **Easy deployment** - single Docker image or Helm chart, configure with one YAML file
- **Preview common file types** - images, video, PDF, and text without downloading
## Quick Start ## Quick Start
@@ -247,7 +248,7 @@ Roughly ordered by value. Open an [issue](https://github.com/Noooste/garage-ui/i
- [x] **Bucket quotas**: size and object count limits from bucket settings - [x] **Bucket quotas**: size and object count limits from bucket settings
- [x] **Zero-config startup**: run straight from `garage.toml`, log in with the admin token - [x] **Zero-config startup**: run straight from `garage.toml`, log in with the admin token
- [x] **Broad compatibility**: Garage v1 through latest, IPv6-only networks, secrets from files - [x] **Broad compatibility**: Garage v1 through latest, IPv6-only networks, secrets from files
- [ ] **Inline object preview**: images, video, PDF, and text without downloading ([#60](https://github.com/Noooste/garage-ui/issues/60)) - [X] **Inline object preview**: images, video, PDF, and text without downloading ([#60](https://github.com/Noooste/garage-ui/issues/60))
- [ ] **Presigned share links**: time-limited download links from the object browser - [ ] **Presigned share links**: time-limited download links from the object browser
- [ ] **Resumable uploads**: multipart uploads that survive a dropped connection - [ ] **Resumable uploads**: multipart uploads that survive a dropped connection
- [ ] **Visual layout editor**: staged vs. applied diff before committing layout changes - [ ] **Visual layout editor**: staged vs. applied diff before committing layout changes
+209
View File
@@ -0,0 +1,209 @@
// Command seed bulk-loads a Garage/S3 bucket with millions of small objects
// for testing the object browser and search features.
//
// Keys are a deterministic function of a global index, so a run is resumable:
// re-run with -start=<last index> and any re-uploaded key simply overwrites.
// Layout: pets/<species>/<breed>/<name>-<NNNNNN>.dat
//
// cd backend && go run ./cmd/seed # full 3,000,000 objects
// go run ./cmd/seed -count 10000 # quick smoke test
// go run ./cmd/seed -start 1500000 # resume from index 1.5M
package main
import (
"bytes"
"context"
"flag"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// species -> breeds. Real words so keys are searchable by substring
// (e.g. "golden", "retriever", "siamese"). Flattened into speciesBreed at init.
var taxonomy = []struct {
species string
breeds []string
}{
{"dogs", []string{"labrador", "golden-retriever", "german-shepherd", "bulldog", "poodle", "beagle", "rottweiler", "dachshund", "husky", "chihuahua"}},
{"cats", []string{"siamese", "persian", "maine-coon", "bengal", "ragdoll", "sphynx", "british-shorthair", "abyssinian"}},
{"birds", []string{"parrot", "canary", "cockatiel", "budgie", "finch", "macaw", "lovebird"}},
{"fish", []string{"goldfish", "guppy", "betta", "angelfish", "tetra", "molly"}},
{"rabbits", []string{"holland-lop", "netherland-dwarf", "rex", "lionhead", "flemish-giant"}},
{"hamsters", []string{"syrian", "dwarf-campbell", "roborovski", "chinese"}},
{"reptiles", []string{"leopard-gecko", "iguana", "bearded-dragon", "corn-snake", "box-turtle"}},
{"horses", []string{"arabian", "thoroughbred", "mustang", "clydesdale", "appaloosa"}},
{"guinea-pigs", []string{"american", "abyssinian", "peruvian", "silkie"}},
{"ferrets", []string{"sable", "albino", "cinnamon", "chocolate"}},
}
// petNames are the leaf file names. ~50 common pet names.
var petNames = []string{
"buddy", "luna", "max", "bella", "charlie", "lucy", "cooper", "daisy", "rocky", "molly",
"bailey", "sadie", "duke", "maggie", "bear", "sophie", "tucker", "chloe", "oliver", "lola",
"jack", "zoe", "toby", "ruby", "teddy", "rosie", "milo", "gracie", "oscar", "coco",
"leo", "penny", "rex", "willow", "sam", "honey", "gus", "ginger", "murphy", "olive",
"jasper", "hazel", "finn", "ivy", "louie", "pepper", "ziggy", "nala", "apollo", "cleo",
}
type speciesBreedPair struct{ species, breed string }
var speciesBreed []speciesBreedPair
func init() {
for _, t := range taxonomy {
for _, b := range t.breeds {
speciesBreed = append(speciesBreed, speciesBreedPair{t.species, b})
}
}
}
// keyFor maps a global index to a unique object key with even folder fill.
// folderIdx selects the species/breed folder; within selects (name, suffix)
// inside that folder. The mapping is a bijection, so keys never collide.
func keyFor(i int64) string {
c := int64(len(speciesBreed))
n := int64(len(petNames))
folderIdx := i % c
within := i / c
name := petNames[within%n]
suffix := within / n
sb := speciesBreed[folderIdx]
return fmt.Sprintf("pets/%s/%s/%s-%06d.dat", sb.species, sb.breed, name, suffix)
}
func main() {
var (
endpoint = flag.String("endpoint", "localhost:3900", "S3 endpoint host:port")
bucket = flag.String("bucket", "test", "target bucket")
region = flag.String("region", "garage", "S3 region")
accessKey = flag.String("access-key", "GK4b706791e6efb7bc00a99c69", "S3 access key")
secretKey = flag.String("secret-key", "cdb665539872887e4fca34841ad2ebd79cda7af2302b500097262ec030123b14", "S3 secret key")
count = flag.Int64("count", 3_000_000, "total dataset size (upper index, exclusive)")
start = flag.Int64("start", 0, "start index (resume point)")
size = flag.Int("size", 4096, "bytes per object")
concurrency = flag.Int("concurrency", 64, "concurrent upload workers")
secure = flag.Bool("secure", false, "use HTTPS")
)
flag.Parse()
client, err := minio.New(*endpoint, &minio.Options{
Creds: credentials.NewStaticV4(*accessKey, *secretKey, ""),
Secure: *secure,
Region: *region,
BucketLookup: minio.BucketLookupPath, // Garage needs path-style
})
if err != nil {
log.Fatalf("client init: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
total := *count - *start
if total <= 0 {
log.Fatalf("nothing to do: start=%d >= count=%d", *start, *count)
}
log.Printf("seeding bucket %q: indices [%d,%d) = %d objects of %d bytes, concurrency=%d, folders=%d",
*bucket, *start, *count, total, *size, *concurrency, len(speciesBreed))
var (
cursor = *start
done int64
errCount int64
wg sync.WaitGroup
startTime = time.Now()
)
// Progress reporter.
reportDone := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var last int64
lastT := startTime
for {
select {
case <-reportDone:
return
case now := <-ticker.C:
d := atomic.LoadInt64(&done)
cur := atomic.LoadInt64(&cursor)
instRate := float64(d-last) / now.Sub(lastT).Seconds()
last, lastT = d, now
var eta time.Duration
if instRate > 0 {
eta = time.Duration(float64(total-d)/instRate) * time.Second
}
log.Printf("progress: %d/%d (%.1f%%) | %.0f obj/s | errors=%d | next-index=%d | eta=%s",
d, total, 100*float64(d)/float64(total), instRate,
atomic.LoadInt64(&errCount), cur, eta.Round(time.Second))
}
}
}()
opts := minio.PutObjectOptions{ContentType: "application/octet-stream", DisableMultipart: true}
for w := 0; w < *concurrency; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
// One random, incompressible buffer per worker (Garage compresses
// per-object, so reuse is fine and avoids per-object allocation).
buf := make([]byte, *size)
r := rand.New(rand.NewSource(int64(1000 + worker)))
for j := range buf {
buf[j] = byte(r.Intn(256))
}
for {
idx := atomic.AddInt64(&cursor, 1) - 1
if idx >= *count {
return
}
if ctx.Err() != nil {
return
}
key := keyFor(idx)
var putErr error
for attempt := 0; attempt < 3; attempt++ {
_, putErr = client.PutObject(ctx, *bucket, key, bytes.NewReader(buf), int64(*size), opts)
if putErr == nil || ctx.Err() != nil {
break
}
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
}
if putErr != nil {
if n := atomic.AddInt64(&errCount, 1); n <= 10 {
log.Printf("put %q failed: %v", key, putErr)
}
continue
}
atomic.AddInt64(&done, 1)
}
}(w)
}
wg.Wait()
close(reportDone)
elapsed := time.Since(startTime)
d := atomic.LoadInt64(&done)
log.Printf("DONE: uploaded %d/%d objects in %s (%.0f obj/s), errors=%d",
d, total, elapsed.Round(time.Second), float64(d)/elapsed.Seconds(), atomic.LoadInt64(&errCount))
if ctx.Err() != nil {
log.Printf("interrupted; resume with -start=%d", atomic.LoadInt64(&cursor))
}
if atomic.LoadInt64(&errCount) > 0 {
os.Exit(1)
}
}
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// PreviewTokenLocalsKey is the fiber Locals key the auth middleware sets
// after validating a preview token. The authz middleware reads it to
// authorize the request without a subject.
const PreviewTokenLocalsKey = "previewTokenClaims"
// PreviewClaims identify the single object a preview token can read.
type PreviewClaims struct {
Bucket string `json:"b"`
Key string `json:"k"`
ExpiresAt int64 `json:"exp"`
}
// previewSecret derives the HMAC key from the JWT signing key. A configured
// session key therefore keeps preview URLs valid across restarts, and a
// generated key invalidates them on restart, which the frontend recovers
// from by minting a fresh URL.
func (j *JWTService) previewSecret() []byte {
j.mu.RLock()
defer j.mu.RUnlock()
sum := sha256.Sum256(append([]byte("garage-ui-preview-token:"), j.privateKey...))
return sum[:]
}
// MintPreviewToken signs a token granting read access to one exact object
// until the TTL elapses.
func (a *Service) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
expiresAt := time.Now().Add(ttl)
token, err := mintPreviewToken(a.jwtService.previewSecret(), bucket, key, expiresAt)
return token, expiresAt, err
}
// ValidatePreviewToken checks the signature, expiry, and exact object match.
func (a *Service) ValidatePreviewToken(token, bucket, key string) error {
return verifyPreviewToken(a.jwtService.previewSecret(), token, bucket, key, time.Now())
}
func mintPreviewToken(secret []byte, bucket, key string, expiresAt time.Time) (string, error) {
payload, err := json.Marshal(PreviewClaims{Bucket: bucket, Key: key, ExpiresAt: expiresAt.Unix()})
// Defensive and unreachable: marshaling a struct of two strings and an int64
// cannot fail, so this branch stays uncovered by design.
if err != nil {
return "", fmt.Errorf("failed to encode preview claims: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return encoded + "." + signPreview(secret, encoded), nil
}
func verifyPreviewToken(secret []byte, token, bucket, key string, now time.Time) error {
encoded, sig, ok := strings.Cut(token, ".")
if !ok {
return fmt.Errorf("malformed preview token")
}
if !hmac.Equal([]byte(sig), []byte(signPreview(secret, encoded))) {
return fmt.Errorf("invalid preview token signature")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return fmt.Errorf("malformed preview token payload")
}
var claims PreviewClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return fmt.Errorf("malformed preview token claims")
}
if now.Unix() > claims.ExpiresAt {
return fmt.Errorf("preview token expired")
}
if claims.Bucket != bucket || claims.Key != key {
return fmt.Errorf("preview token does not match the requested object")
}
return nil
}
func signPreview(secret []byte, encoded string) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(encoded))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
+122
View File
@@ -0,0 +1,122 @@
package auth
import (
"encoding/base64"
"strings"
"testing"
"time"
)
func newPreviewTestService(t *testing.T) *Service {
t.Helper()
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
return &Service{jwtService: jwtSvc}
}
func TestPreviewToken_RoundTrip(t *testing.T) {
svc := newPreviewTestService(t)
token, expiresAt, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if remaining := time.Until(expiresAt); remaining < 59*time.Minute || remaining > time.Hour {
t.Errorf("expiresAt %v is not about an hour away", expiresAt)
}
if err := svc.ValidatePreviewToken(token, "b1", "dir/clip.mp4"); err != nil {
t.Errorf("ValidatePreviewToken: %v", err)
}
}
func TestPreviewToken_Expired(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", -time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected expired token to be rejected")
}
}
func TestPreviewToken_WrongObject(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k1", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b2", "k1"); err == nil {
t.Error("expected wrong bucket to be rejected")
}
if err := svc.ValidatePreviewToken(token, "b1", "k2"); err == nil {
t.Error("expected wrong key to be rejected")
}
}
func TestPreviewToken_Tampered(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
payload, sig, _ := strings.Cut(token, ".")
flipped := "A" + payload[1:]
if flipped == payload {
flipped = "B" + payload[1:]
}
if err := svc.ValidatePreviewToken(flipped+"."+sig, "b1", "k"); err == nil {
t.Error("expected tampered payload to be rejected")
}
if err := svc.ValidatePreviewToken(payload+".AAAA", "b1", "k"); err == nil {
t.Error("expected tampered signature to be rejected")
}
}
func TestPreviewToken_Malformed(t *testing.T) {
svc := newPreviewTestService(t)
for _, tok := range []string{"", "nodot", "a.b.c", "!!!.???", "bm90anNvbg.sig"} {
if err := svc.ValidatePreviewToken(tok, "b1", "k"); err == nil {
t.Errorf("expected malformed token %q to be rejected", tok)
}
}
}
func TestPreviewToken_DifferentServicesRejectEachOther(t *testing.T) {
a := newPreviewTestService(t)
b := newPreviewTestService(t)
token, _, err := a.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := b.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a token from another key to be rejected")
}
}
// TestPreviewToken_ValidSignatureMalformedBase64Payload signs a payload that
// is not valid RawURLEncoding, so the signature check passes but the base64
// decode fails. This exercises the decode error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureMalformedBase64Payload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := "!!not-base64!!"
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-base64 payload to be rejected")
}
}
// TestPreviewToken_ValidSignatureNonJSONPayload signs a valid base64 payload
// whose bytes are not JSON, so the signature and decode both pass but the
// unmarshal fails. This exercises the JSON error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureNonJSONPayload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := base64.RawURLEncoding.EncodeToString([]byte("not json"))
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-JSON payload to be rejected")
}
}
+9
View File
@@ -103,6 +103,15 @@ func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler
if !m.enabled { if !m.enabled {
return c.Next() return c.Next()
} }
// A validated preview token authorizes exactly one thing: object.read
// on the object it names. The auth middleware set these claims after
// verifying the signature and the bucket and key match this request.
if claims, ok := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims); ok && claims != nil {
if len(perms) == 1 && perms[0] == PermObjectRead && scope(c).Bucket == claims.Bucket {
logDecision(c, "preview-token", PermObjectRead, claims.Bucket, true, "preview_token")
return c.Next()
}
}
subj, ok := SubjectFrom(c) subj, ok := SubjectFrom(c)
if !ok { if !ok {
logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject") logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject")
+50
View File
@@ -217,6 +217,56 @@ func TestRequireZeroPermissionsPanics(t *testing.T) {
m.Require(ScopeNone) m.Require(ScopeNone)
} }
func newPreviewClaimsApp(m *Middleware, claims *auth.PreviewClaims) *fiber.App {
app := fiber.New()
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware validating a preview token
if claims != nil {
c.Locals(auth.PreviewTokenLocalsKey, claims)
}
return c.Next()
})
app.Use(m.ResolveSubject())
app.Get("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectRead), func(c fiber.Ctx) error {
return c.SendString("bytes")
})
app.Delete("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectDelete), func(c fiber.Ctx) error {
return c.SendString("deleted")
})
return app
}
func TestRequirePreviewTokenBypass(t *testing.T) {
m := middlewareFixture(t)
t.Run("matching bucket allows object read without a subject", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 200 {
t.Errorf("status = %d, want 200", code)
}
})
t.Run("bucket mismatch denies", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "bucket-a", Key: "k"})
if code := doReq(t, app, "GET", "/api/v1/buckets/bucket-b/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
t.Run("other permissions stay denied", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
if code := doReq(t, app, "DELETE", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
t.Run("no claims still requires a subject", func(t *testing.T) {
app := newPreviewClaimsApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
}
func TestVerifyRouteCoverage_UseRegisteredEndpointFlagged(t *testing.T) { func TestVerifyRouteCoverage_UseRegisteredEndpointFlagged(t *testing.T) {
// A .Use()-registered route at a DEEPER path under /api/v1 is a reachable // A .Use()-registered route at a DEEPER path under /api/v1 is a reachable
// endpoint (Fiber runs it for every method with that prefix); it must get // endpoint (Fiber runs it for every method with that prefix); it must get
+125 -15
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"bufio"
"io" "io"
"net/url" "net/url"
"path" "path"
@@ -68,15 +67,27 @@ func contentDispositionHeader(disposition, key string) string {
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
} }
// PreviewTokenMinter mints signed single-object preview tokens.
// auth.Service satisfies it.
type PreviewTokenMinter interface {
MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error)
}
// previewTokenTTL is long enough that seeking mid-playback keeps working.
// The frontend mints a fresh URL when a token expires.
const previewTokenTTL = time.Hour
// ObjectHandler handles object-related HTTP requests. // ObjectHandler handles object-related HTTP requests.
type ObjectHandler struct { type ObjectHandler struct {
s3Service services.S3Storage s3Service services.S3Storage
previewTokens PreviewTokenMinter
} }
// NewObjectHandler creates a new object handler. // NewObjectHandler creates a new object handler.
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler { func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMinter) *ObjectHandler {
return &ObjectHandler{ return &ObjectHandler{
s3Service: s3Service, s3Service: s3Service,
previewTokens: previewTokens,
} }
} }
@@ -277,12 +288,8 @@ func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" // @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Router /api/v1/buckets/{bucket}/objects/{key} [get] // @Router /api/v1/buckets/{bucket}/objects/{key} [get]
func (h *ObjectHandler) GetObject(c fiber.Ctx) error { func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameters
bucketName := c.Params("bucket") bucketName := c.Params("bucket")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string) key, ok := c.Locals("objectKey").(string)
if !ok || key == "" { if !ok || key == "" {
key = c.Params("key") key = c.Params("key")
@@ -294,7 +301,17 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
) )
} }
// Get object from Garage // Range requests stream a partial body so media elements can seek.
if rangeHeader := c.Get("Range"); rangeHeader != "" {
return h.getObjectRange(c, bucketName, key, rangeHeader)
}
return h.serveFullObject(c, bucketName, key)
}
// serveFullObject streams the whole object with a 200, the pre-Range behavior.
func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) error {
ctx := c.Context()
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key) body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).JSON( return c.Status(fiber.StatusNotFound).JSON(
@@ -307,11 +324,12 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
// cannot run as XSS in the SPA origin when fetched inline. // cannot run as XSS in the SPA origin when fetched inline.
c.Set("Content-Type", safeContentType(objectInfo.ContentType)) c.Set("Content-Type", safeContentType(objectInfo.ContentType))
c.Set("X-Content-Type-Options", "nosniff") c.Set("X-Content-Type-Options", "nosniff")
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10)) c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
c.Set("ETag", objectInfo.ETag) c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123)) c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// The object key is attacker-controlled — build the header via the safe // The object key is attacker-controlled. Build the header via the safe
// RFC 6266 helper to avoid quote/semicolon injection into filename=. // RFC 6266 helper to avoid quote/semicolon injection into filename=.
disposition := "inline" disposition := "inline"
if c.Query("download") == "true" { if c.Query("download") == "true" {
@@ -319,11 +337,60 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
} }
c.Set("Content-Disposition", contentDispositionHeader(disposition, key)) c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
// Stream the object body to the client without buffering the entire file // SendStream (not SendStreamWriter) keeps the declared Content-Length: the
return c.SendStreamWriter(func(w *bufio.Writer) { // streaming writer variant forces fasthttp into unknown-length chunked
defer body.Close() // transfer, dropping the header we just set above.
io.Copy(w, body) return c.SendStream(body, int(objectInfo.Size))
}) }
// getObjectRange serves a single-range request with 206 Partial Content.
// Malformed and multi-range headers fall back to the full 200 response.
func (h *ObjectHandler) getObjectRange(c fiber.Ctx, bucketName, key, rangeHeader string) error {
ctx := c.Context()
info, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
rng, unsatisfiable := parseRangeHeader(rangeHeader, info.Size)
if unsatisfiable {
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Range", "bytes */"+strconv.FormatInt(info.Size, 10))
return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
}
if rng == nil {
return h.serveFullObject(c, bucketName, key)
}
body, err := h.s3Service.GetObjectRange(ctx, bucketName, key, rng.start, rng.end)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
c.Set("Content-Type", safeContentType(info.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Length", strconv.FormatInt(rng.end-rng.start+1, 10))
c.Set("Content-Range", "bytes "+strconv.FormatInt(rng.start, 10)+"-"+strconv.FormatInt(rng.end, 10)+"/"+strconv.FormatInt(info.Size, 10))
c.Set("ETag", info.ETag)
c.Set("Last-Modified", info.LastModified.Format(time.RFC1123))
disposition := "inline"
if c.Query("download") == "true" {
disposition = "attachment"
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
c.Status(fiber.StatusPartialContent)
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
// streaming writer variant forces fasthttp into unknown-length chunked
// transfer, dropping the header we just set above.
return c.SendStream(body, int(rng.end-rng.start+1))
} }
// DeleteObject deletes an object from a bucket // DeleteObject deletes an object from a bucket
@@ -428,6 +495,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
) )
} }
c.Set("Accept-Ranges", "bytes")
return c.JSON(models.SuccessResponse(metadata)) return c.JSON(models.SuccessResponse(metadata))
} }
@@ -512,6 +580,48 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(response)) return c.JSON(models.SuccessResponse(response))
} }
// GetPreviewURL mints a short-lived tokenized URL for streaming this object
//
// @Summary Get a tokenized preview URL for an object
// @Description Returns a relative URL carrying a short-lived token that authorizes streaming this object. Media elements cannot send an Authorization header, so the token rides in the URL instead.
// @Tags Objects
// @Produce json
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Success 200 {object} models.APIResponse{data=models.PreviewURLResponse} "Preview URL minted"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to mint the preview token"
// @Router /api/v1/buckets/{bucket}/objects/{key}/preview-url [get]
func (h *ObjectHandler) GetPreviewURL(c fiber.Ctx) error {
bucketName := c.Params("bucket")
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
token, expiresAt, err := h.previewTokens.MintPreviewToken(bucketName, key, previewTokenTTL)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to mint the preview token: "+err.Error()),
)
}
previewURL := "/api/v1/buckets/" + url.PathEscape(bucketName) +
"/objects/" + url.PathEscape(key) + "?pt=" + url.QueryEscape(token)
return c.JSON(models.SuccessResponse(models.PreviewURLResponse{
URL: previewURL,
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
}))
}
// DeleteMultipleObjects deletes multiple objects from a bucket // DeleteMultipleObjects deletes multiple objects from a bucket
// //
// @Summary Delete multiple objects from bucket // @Summary Delete multiple objects from bucket
+308 -3
View File
@@ -20,23 +20,42 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
// mintStub satisfies PreviewTokenMinter for handler tests.
type mintStub struct {
fn func(bucket, key string, ttl time.Duration) (string, time.Time, error)
}
func (m *mintStub) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
if m.fn == nil {
return "test-token", time.Now().Add(ttl), nil
}
return m.fn(bucket, key, ttl)
}
func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) { func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
app, s3, _ := newObjectsTestAppWithMinter(t)
return app, s3
}
func newObjectsTestAppWithMinter(t *testing.T) (*fiber.App, *mocks.S3Mock, *mintStub) {
t.Helper() t.Helper()
s3 := &mocks.S3Mock{} s3 := &mocks.S3Mock{}
h := NewObjectHandler(s3) minter := &mintStub{}
h := NewObjectHandler(s3, minter)
app := fiber.New() app := fiber.New()
app.Get("/buckets/:bucket/objects", h.ListObjects) app.Get("/buckets/:bucket/objects", h.ListObjects)
app.Post("/buckets/:bucket/objects", h.UploadObject) app.Post("/buckets/:bucket/objects", h.UploadObject)
app.Post("/buckets/:bucket/directories", h.CreateDirectory) app.Post("/buckets/:bucket/directories", h.CreateDirectory)
app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects) app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects) app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
// Wildcard endpoints — mount under :key for tests. Handlers prefer // Wildcard endpoints. Mount under :key for tests. Handlers prefer
// c.Locals("objectKey") but fall back to c.Params("key"), so :key works. // c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
app.Get("/buckets/:bucket/objects/:key", h.GetObject) app.Get("/buckets/:bucket/objects/:key", h.GetObject)
app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata) app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL) app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
app.Get("/buckets/:bucket/objects/:key/preview-url", h.GetPreviewURL)
app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject) app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
return app, s3 return app, s3, minter
} }
// --- ListObjects --- // --- ListObjects ---
@@ -343,6 +362,109 @@ func TestGetPresignedURL_ObjectMissing404(t *testing.T) {
} }
} }
// --- GetPreviewURL ---
func TestGetPreviewURL_Success(t *testing.T) {
app, _, minter := newObjectsTestAppWithMinter(t)
fixed := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
minter.fn = func(bucket, key string, ttl time.Duration) (string, time.Time, error) {
if bucket != "b1" || key != "clip.mp4" {
t.Errorf("mint args = (%q, %q)", bucket, key)
}
if ttl != time.Hour {
t.Errorf("ttl = %v, want 1h", ttl)
}
return "tok123", fixed, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4/preview-url", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.PreviewURLResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data.URL != "/api/v1/buckets/b1/objects/clip.mp4?pt=tok123" {
t.Errorf("url = %q", body.Data.URL)
}
if body.Data.ExpiresAt != "2026-07-11T12:00:00Z" {
t.Errorf("expires_at = %q", body.Data.ExpiresAt)
}
}
func TestGetPreviewURL_EscapesKeyInURL(t *testing.T) {
// Production sets the decoded key in locals via the wildcard dispatcher,
// so mirror that here instead of relying on :key param decoding.
s3 := &mocks.S3Mock{}
minter := &mintStub{}
minter.fn = func(_, key string, _ time.Duration) (string, time.Time, error) {
if key != "dir/my file.mp4" {
t.Errorf("key = %q", key)
}
return "tok", time.Now().Add(time.Hour), nil
}
h := NewObjectHandler(s3, minter)
app := fiber.New()
app.Get("/buckets/:bucket/preview-url", func(c fiber.Ctx) error {
c.Locals("objectKey", "dir/my file.mp4")
return h.GetPreviewURL(c)
})
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/preview-url", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.PreviewURLResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if !strings.HasPrefix(body.Data.URL, "/api/v1/buckets/b1/objects/dir%2Fmy%20file.mp4?pt=") {
t.Errorf("url = %q, want the key percent-encoded whole", body.Data.URL)
}
}
func TestGetPreviewURL_MissingBucketAndKey400(t *testing.T) {
// Mount on a route with no :bucket param and no objectKey local, so both
// bucket and key are empty and the handler short-circuits with 400.
s3 := &mocks.S3Mock{}
minter := &mintStub{}
h := NewObjectHandler(s3, minter)
app := fiber.New()
app.Get("/preview-url-nobucket", h.GetPreviewURL)
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/preview-url-nobucket", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestGetPreviewURL_MintError500(t *testing.T) {
app, _, minter := newObjectsTestAppWithMinter(t)
minter.fn = func(_, _ string, _ time.Duration) (string, time.Time, error) {
return "", time.Time{}, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f/preview-url", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- GetObject --- // --- GetObject ---
func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) { func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
@@ -1033,3 +1155,186 @@ func TestCreateDirectory_ServiceError500(t *testing.T) {
t.Fatalf("status = %d, want 500", resp.StatusCode) t.Fatalf("status = %d, want 500", resp.StatusCode)
} }
} }
// --- GetObject Range support ---
func TestGetObject_NoRangeHeaderAdvertisesAcceptRanges(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, ContentType: "text/plain", LastModified: time.Now()}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.txt", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
}
}
func TestGetObject_RangeRequestServes206(t *testing.T) {
app, s3 := newObjectsTestApp(t)
now := time.Now()
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4", ETag: "e1", LastModified: now}, nil
}
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
if start != 2 || end != 6 {
t.Errorf("range = %d-%d, want 2-6", start, end)
}
return io.NopCloser(strings.NewReader("23456")), nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
req.Header.Set("Range", "bytes=2-6")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", resp.StatusCode)
}
if got := resp.Header.Get("Content-Range"); got != "bytes 2-6/10" {
t.Errorf("Content-Range = %q, want %q", got, "bytes 2-6/10")
}
if got := resp.Header.Get("Content-Length"); got != "5" {
t.Errorf("Content-Length = %q, want %q", got, "5")
}
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "23456" {
t.Errorf("body = %q, want %q", body, "23456")
}
}
func TestGetObject_UnsatisfiableRangeServes416(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10}, nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
req.Header.Set("Range", "bytes=50-")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("status = %d, want 416", resp.StatusCode)
}
if got := resp.Header.Get("Content-Range"); got != "bytes */10" {
t.Errorf("Content-Range = %q, want %q", got, "bytes */10")
}
}
func TestGetObject_MultiRangeFallsBackToFullResponse(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10}, nil
}
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, LastModified: time.Now()}, nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
req.Header.Set("Range", "bytes=0-1,3-4")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "0123456789" {
t.Errorf("body = %q, want the full object", body)
}
}
func TestGetObject_RangeForMissingObjectIs404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return nil, errors.New("no such key")
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/gone.bin", nil)
req.Header.Set("Range", "bytes=0-5")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
// A ranged read whose metadata resolves but whose byte fetch fails, for example
// when the object is deleted between the two calls, returns 404.
func TestGetObject_RangeReadErrorIs404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10}, nil
}
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
return nil, errors.New("read failed")
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
req.Header.Set("Range", "bytes=0-5")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
// A ranged request with download=true still streams 206 but marks the body as
// an attachment instead of inline.
func TestGetObject_RangeWithDownloadSetsAttachment(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4"}, nil
}
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("01234")), nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4?download=true", nil)
req.Header.Set("Range", "bytes=0-4")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", resp.StatusCode)
}
if got := resp.Header.Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
t.Errorf("Content-Disposition = %q, want attachment", got)
}
}
// GetObject rejects a request that resolves to an empty object key with 400.
// This guards the wildcard dispatch path where the key comes from locals.
func TestGetObject_EmptyKeyIsBadRequest(t *testing.T) {
s3 := &mocks.S3Mock{}
h := NewObjectHandler(s3, &mintStub{})
app := fiber.New()
// Mounted without a :key param so the handler resolves an empty key.
app.Get("/buckets/:bucket/object", h.GetObject)
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/object", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
+68
View File
@@ -0,0 +1,68 @@
package handlers
import (
"strconv"
"strings"
)
// byteRange is a resolved, inclusive byte range within an object.
type byteRange struct {
start int64
end int64
}
// parseRangeHeader resolves a Range request header against the object size.
// It supports a single "bytes=" range in its three forms: start-end, start-,
// and -suffix. A nil result with unsatisfiable false means serve the full
// object with 200; absent, malformed, and multi-range headers all land there,
// which RFC 9110 permits. unsatisfiable true means respond 416.
func parseRangeHeader(header string, size int64) (rng *byteRange, unsatisfiable bool) {
spec, ok := strings.CutPrefix(header, "bytes=")
if !ok {
return nil, false
}
spec = strings.TrimSpace(spec)
if strings.Contains(spec, ",") {
return nil, false
}
startStr, endStr, ok := strings.Cut(spec, "-")
if !ok {
return nil, false
}
startStr = strings.TrimSpace(startStr)
endStr = strings.TrimSpace(endStr)
// Suffix form "-n" asks for the last n bytes.
if startStr == "" {
n, err := strconv.ParseInt(endStr, 10, 64)
if err != nil {
return nil, false
}
if n <= 0 || size == 0 {
return nil, true
}
if n > size {
n = size
}
return &byteRange{start: size - n, end: size - 1}, false
}
start, err := strconv.ParseInt(startStr, 10, 64)
if err != nil || start < 0 {
return nil, false
}
if start >= size {
return nil, true
}
if endStr == "" {
return &byteRange{start: start, end: size - 1}, false
}
end, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || end < start {
return nil, false
}
if end >= size {
end = size - 1
}
return &byteRange{start: start, end: end}, false
}
+54
View File
@@ -0,0 +1,54 @@
package handlers
import "testing"
func TestParseRangeHeader(t *testing.T) {
cases := []struct {
name string
header string
size int64
wantStart int64
wantEnd int64
wantRange bool
wantUnsatisfy bool
}{
{name: "absent header serves full", header: "", size: 10, wantRange: false},
{name: "simple range", header: "bytes=2-6", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
{name: "open ended", header: "bytes=500-", size: 1000, wantStart: 500, wantEnd: 999, wantRange: true},
{name: "suffix", header: "bytes=-300", size: 1000, wantStart: 700, wantEnd: 999, wantRange: true},
{name: "suffix larger than object clamps to full", header: "bytes=-5000", size: 1000, wantStart: 0, wantEnd: 999, wantRange: true},
{name: "end clamped to size", header: "bytes=0-99999", size: 100, wantStart: 0, wantEnd: 99, wantRange: true},
{name: "single byte", header: "bytes=0-0", size: 10, wantStart: 0, wantEnd: 0, wantRange: true},
{name: "start beyond size is unsatisfiable", header: "bytes=100-", size: 100, wantUnsatisfy: true},
{name: "suffix zero is unsatisfiable", header: "bytes=-0", size: 100, wantUnsatisfy: true},
{name: "any range on empty object is unsatisfiable", header: "bytes=0-", size: 0, wantUnsatisfy: true},
{name: "suffix on empty object is unsatisfiable", header: "bytes=-5", size: 0, wantUnsatisfy: true},
{name: "multi range ignored", header: "bytes=0-1,3-4", size: 10, wantRange: false},
{name: "non byte unit ignored", header: "items=0-5", size: 10, wantRange: false},
{name: "end before start ignored", header: "bytes=6-2", size: 10, wantRange: false},
{name: "garbage start ignored", header: "bytes=abc-5", size: 10, wantRange: false},
{name: "garbage end ignored", header: "bytes=5-abc", size: 10, wantRange: false},
{name: "garbage suffix ignored", header: "bytes=-abc", size: 10, wantRange: false},
{name: "negative start ignored", header: "bytes=-5-8", size: 10, wantRange: false},
{name: "missing dash ignored", header: "bytes=5", size: 10, wantRange: false},
{name: "whitespace tolerated", header: "bytes= 2-6 ", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rng, unsatisfiable := parseRangeHeader(tc.header, tc.size)
if unsatisfiable != tc.wantUnsatisfy {
t.Fatalf("unsatisfiable = %v, want %v", unsatisfiable, tc.wantUnsatisfy)
}
if tc.wantRange {
if rng == nil {
t.Fatalf("rng = nil, want %d-%d", tc.wantStart, tc.wantEnd)
}
if rng.start != tc.wantStart || rng.end != tc.wantEnd {
t.Errorf("rng = %d-%d, want %d-%d", rng.start, rng.end, tc.wantStart, tc.wantEnd)
}
} else if rng != nil {
t.Errorf("rng = %d-%d, want nil", rng.start, rng.end)
}
})
}
}
+84
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"net/url"
"strings" "strings"
"Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/auth"
@@ -23,6 +24,31 @@ func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Han
return c.Next() return c.Next()
} }
// Preview tokens authenticate object GETs from media elements, which
// cannot send an Authorization header. The token was minted behind an
// object.read check, names one exact object, and expires on its own.
//
// Bucket and key come from the raw request path (previewRouteParts),
// not from c.Params("bucket")/c.Params("*"). routes.go registers this
// AuthMiddleware twice for the object GET route: once cascaded from
// the /api/v1 group's Use middleware (which runs before Fiber has
// matched the specific wildcard route, so its params are not bound
// yet) and once more directly on the route itself (params bound). The
// group-cascaded pass would otherwise dead-end here on empty params
// and fall through to a 401 before the bound-params pass ever runs.
// Parsing the static path shape gives the same, correct answer in
// both positions without weakening the contract: it only ever
// resolves the exact bucket and key named in the URL.
if pt := c.Query("pt"); pt != "" && c.Method() == fiber.MethodGet {
bucket, _ := previewRouteParts(c)
key := previewObjectKey(c)
if bucket != "" && key != "" && authService.ValidatePreviewToken(pt, bucket, key) == nil {
c.Locals(auth.PreviewTokenLocalsKey, &auth.PreviewClaims{Bucket: bucket, Key: key})
enrichRequestLogger(c, "preview-token", "preview_token")
return c.Next()
}
}
authHeader := c.Get("Authorization") authHeader := c.Get("Authorization")
// Try bearer token auth (works for admin, token, or any JWT session) // Try bearer token auth (works for admin, token, or any JWT session)
@@ -97,3 +123,61 @@ func authMethodsEnabled(cfg *config.AuthConfig) string {
} }
return strings.Join(methods, "+") return strings.Join(methods, "+")
} }
// previewRouteParts extracts the bucket and raw (still percent-encoded)
// object key from a request path shaped like
// "/api/v1/buckets/<bucket>/objects/<key>", the only shape the object GET
// route matches. It parses c.Path() directly rather than reading Fiber's
// bound :bucket/* route params, because those params are unset whenever this
// runs ahead of the specific route match (see the AuthMiddleware comment
// above). c.Path() reflects the incoming request path from the start of
// request handling, independent of routing state, so this returns the same
// answer no matter where in the chain it runs. Any path not matching the
// shape returns ("", "").
func previewRouteParts(c fiber.Ctx) (bucket, rawKey string) {
const prefix = "/api/v1/buckets/"
path := c.Path()
if !strings.HasPrefix(path, prefix) {
return "", ""
}
rest := path[len(prefix):]
slash := strings.IndexByte(rest, '/')
if slash < 0 {
return "", ""
}
bucket = rest[:slash]
rest = rest[slash+1:]
const objectsPrefix = "objects/"
if !strings.HasPrefix(rest, objectsPrefix) {
return "", ""
}
return bucket, rest[len(objectsPrefix):]
}
// previewObjectKey decodes the wildcard object key the same way the routes
// layer does. Requests targeting the JSON subroutes return "" because a
// preview token only ever grants the plain byte download.
func previewObjectKey(c fiber.Ctx) string {
_, raw := previewRouteParts(c)
// A raw trailing slash is the one case where c.Path() (used here) and the
// served c.Params("*") diverge: Fiber trims the trailing slash from the
// bound wildcard, so validating against the un-trimmed key could authorize
// a token for "dir/" to serve "dir", or let "x/metadata/" reach the
// /metadata subroute. The SPA always percent-encodes keys as one segment,
// so a legitimate key ending in "/" arrives as "...%2F", never a raw
// trailing slash. Refuse the raw-trailing-slash form so the validated key
// can never diverge from the served key.
if raw == "" || strings.HasSuffix(raw, "/") {
return ""
}
decoded, err := url.QueryUnescape(raw)
if err != nil {
decoded = raw
}
for _, suffix := range []string{"/metadata", "/presign", "/preview-url"} {
if strings.HasSuffix(decoded, suffix) {
return ""
}
}
return decoded
}
+207
View File
@@ -3,10 +3,13 @@ package middleware
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
"time"
"Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/config"
@@ -401,3 +404,207 @@ func TestAuthMiddleware_Both_AllInvalid_Returns401WithCombinedMethodLabel(t *tes
t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"]) t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"])
} }
} }
// newPreviewTokenApp mirrors the production object GET route shape. Note it
// registers AuthMiddleware via a bare app.Use(), same as routes.go's /api/v1
// group cascade: bucket and key are read from the raw request path
// (previewRouteParts), not from c.Params("bucket")/c.Params("*"), precisely
// because those Fiber route params are not yet bound when a Use()-registered
// middleware executes ahead of the specific :bucket/* route match. routes.go
// registers AuthMiddleware a second time directly on the object route too;
// this test only needs one registration to exercise the same path-parsing
// code the real group cascade hits first.
func newPreviewTokenApp(t *testing.T, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App {
t.Helper()
app := fiber.New()
app.Use(AuthMiddleware(authCfg, svc))
handler := func(c fiber.Ctx) error {
claims, _ := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims)
if claims == nil {
return c.SendString("no-claims")
}
return c.SendString("claims:" + claims.Bucket + "/" + claims.Key)
}
app.Get("/api/v1/buckets/:bucket/objects/*", handler)
app.Delete("/api/v1/buckets/:bucket/objects/*", handler)
return app
}
func previewAuthConfig() *config.AuthConfig {
return &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
}
}
func TestAuthMiddleware_ValidPreviewTokenAllowsObjectGET(t *testing.T) {
authCfg := previewAuthConfig()
svc := newAuthSvc(t, authCfg)
app := newPreviewTokenApp(t, authCfg, svc)
token, _, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
req := httptest.NewRequest("GET", "/api/v1/buckets/b1/objects/dir%2Fclip.mp4?pt="+url.QueryEscape(token), nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "claims:b1/dir/clip.mp4" {
t.Errorf("body = %q, want the preview claims set in locals", body)
}
}
func TestAuthMiddleware_PreviewTokenRejections(t *testing.T) {
authCfg := previewAuthConfig()
svc := newAuthSvc(t, authCfg)
app := newPreviewTokenApp(t, authCfg, svc)
good, _, err := svc.MintPreviewToken("b1", "k.mp4", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
expired, _, err := svc.MintPreviewToken("b1", "k.mp4", -time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
// A token whose claimed key genuinely ends in a slash. On the wire the SPA
// sends this as one percent-encoded segment ("dir%2F"); this case instead
// sends the ambiguous raw form ("dir/"). c.Path() keeps the trailing
// slash, but the served c.Params("*") would be trimmed to "dir", so the
// token would name "dir/" while the handler serves "dir": a different
// object. previewObjectKey refuses the raw-trailing-slash form, so this
// falls through to normal auth and 401s.
trailingDir, _, err := svc.MintPreviewToken("b1", "dir/", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
// A token for a key ending in "/metadata/". Decoded it is "x/metadata/",
// whose HasSuffix "/metadata" is false because of the trailing slash, so
// the subroute guard would not fire; but the served c.Params("*") is
// trimmed to "x/metadata" and routes to the /metadata subroute. The
// raw-trailing-slash refusal blocks this before either divergence matters.
trailingMeta, _, err := svc.MintPreviewToken("b1", "x/metadata/", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
cases := []struct {
name string
method string
path string
}{
{name: "wrong key", method: "GET", path: "/api/v1/buckets/b1/objects/other.mp4?pt=" + url.QueryEscape(good)},
{name: "wrong bucket", method: "GET", path: "/api/v1/buckets/b2/objects/k.mp4?pt=" + url.QueryEscape(good)},
{name: "expired", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(expired)},
{name: "metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fmetadata?pt=" + url.QueryEscape(good)},
{name: "presign subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpresign?pt=" + url.QueryEscape(good)},
{name: "preview-url subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpreview-url?pt=" + url.QueryEscape(good)},
{name: "delete method", method: "DELETE", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(good)},
{name: "garbage token", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=garbage"},
{name: "raw trailing slash key", method: "GET", path: "/api/v1/buckets/b1/objects/dir/?pt=" + url.QueryEscape(trailingDir)},
{name: "raw trailing slash reaching metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/x/metadata/?pt=" + url.QueryEscape(trailingMeta)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := app.Test(httptest.NewRequest(tc.method, tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Errorf("status = %d, want 401 fallthrough to normal auth", resp.StatusCode)
}
})
}
}
// TestPreviewRouteParts exercises previewRouteParts directly against every
// branch of its path-shape parsing: no "/api/v1/buckets/" prefix, a bucket
// segment with nothing after it, a bucket segment followed by something
// other than "objects/", and the well formed shape. This parsing runs in
// place of Fiber's :bucket/* param binding (see the comment on
// previewRouteParts), so its edge cases need direct coverage independent of
// AuthMiddleware's own tests.
func TestPreviewRouteParts(t *testing.T) {
app := fiber.New()
var gotBucket, gotKey string
app.Get("/*", func(c fiber.Ctx) error {
gotBucket, gotKey = previewRouteParts(c)
return c.SendString("ok")
})
cases := []struct {
name string
path string
wantBucket string
wantKey string
}{
{name: "no buckets prefix", path: "/other/path", wantBucket: "", wantKey: ""},
{name: "bucket segment with no trailing slash", path: "/api/v1/buckets/mybucket", wantBucket: "", wantKey: ""},
{name: "segment after bucket is not objects", path: "/api/v1/buckets/mybucket/permissions", wantBucket: "", wantKey: ""},
{name: "well formed", path: "/api/v1/buckets/mybucket/objects/dir%2Fclip.mp4", wantBucket: "mybucket", wantKey: "dir%2Fclip.mp4"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotBucket, gotKey = "unset", "unset"
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if gotBucket != tc.wantBucket || gotKey != tc.wantKey {
t.Errorf("previewRouteParts(%q) = (%q, %q), want (%q, %q)", tc.path, gotBucket, gotKey, tc.wantBucket, tc.wantKey)
}
})
}
}
// TestPreviewObjectKey covers previewObjectKey's own branches beyond what
// the AuthMiddleware rejection tests exercise incidentally: a plain key with
// no reserved suffix decodes normally, and a path that previewRouteParts
// can't parse at all yields "".
func TestPreviewObjectKey(t *testing.T) {
app := fiber.New()
var got string
app.Get("/*", func(c fiber.Ctx) error {
got = previewObjectKey(c)
return c.SendString("ok")
})
cases := []struct {
name string
path string
want string
}{
{name: "plain key decodes", path: "/api/v1/buckets/b/objects/dir%2Fclip.mp4", want: "dir/clip.mp4"},
{name: "unparseable route returns empty", path: "/not-a-bucket-route", want: ""},
// A key that genuinely ends in a slash is legitimate when the SPA sends
// it as one encoded segment ("dir%2F"): raw has no literal trailing
// slash, so it is accepted and decodes to "dir/", matching the served
// key. Only the raw-trailing-slash form is refused.
{name: "encoded trailing slash accepted", path: "/api/v1/buckets/b/objects/dir%2F", want: "dir/"},
// The ambiguous raw-trailing-slash form is refused (returns "").
{name: "raw trailing slash refused", path: "/api/v1/buckets/b/objects/dir/", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got = "unset"
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got != tc.want {
t.Errorf("previewObjectKey(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
+5
View File
@@ -150,6 +150,11 @@ type PresignedURLResponse struct {
Key string `json:"key"` Key string `json:"key"`
} }
type PreviewURLResponse struct {
URL string `json:"url"`
ExpiresAt string `json:"expires_at"`
}
type ObjectDeleteMultipleResponse struct { type ObjectDeleteMultipleResponse struct {
Bucket string `json:"bucket"` Bucket string `json:"bucket"`
Deleted int `json:"deleted"` Deleted int `json:"deleted"`
+3
View File
@@ -113,6 +113,9 @@ func SetupRoutes(
case strings.HasSuffix(path, "/presign"): case strings.HasSuffix(path, "/presign"):
c.Locals("objectKey", strings.TrimSuffix(path, "/presign")) c.Locals("objectKey", strings.TrimSuffix(path, "/presign"))
return objectHandler.GetPresignedURL(c) return objectHandler.GetPresignedURL(c)
case strings.HasSuffix(path, "/preview-url"):
c.Locals("objectKey", strings.TrimSuffix(path, "/preview-url"))
return objectHandler.GetPreviewURL(c)
default: default:
c.Locals("objectKey", path) c.Locals("objectKey", path)
return objectHandler.GetObject(c) return objectHandler.GetObject(c)
+72 -1
View File
@@ -4,7 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
"net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -100,7 +102,7 @@ func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) {
svc, svc,
handlers.NewHealthHandler("test"), handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3), handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3), handlers.NewObjectHandler(s3, svc),
handlers.NewUserHandler(admin), handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin), handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3), handlers.NewMonitoringHandler(admin, s3),
@@ -229,3 +231,72 @@ func TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions(t *testing.T
t.Error("denied-x should not be visible to a team without bucket.list on that prefix") t.Error("denied-x should not be visible to a team without bucket.list on that prefix")
} }
} }
// TestPreviewTokenGrantsObjectGET exercises the full production chain: group
// cascade AuthMiddleware accepts the token, ResolveSubject finds no user,
// and Require allows via the preview claims instead of a subject.
func TestPreviewTokenGrantsObjectGET(t *testing.T) {
f, _ := newEnabledPolicyFixture(t)
// The full-object body echoes the key the handler was actually asked to
// serve (the decoded c.Params("*")). Asserting the streamed body equals
// the exact key the token was minted for makes any future divergence
// between the validated key and the served key fail loudly here rather
// than hide behind a constant body.
const mintedKey = "media/clip.mp4"
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader(key)), &models.ObjectInfo{Key: key, Size: int64(len(key)), ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 5, ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("ell")), nil
}
token, _, err := f.Auth.MintPreviewToken("allowed-data", mintedKey, time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
tokenized := "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4?pt=" + url.QueryEscape(token)
// No Authorization header anywhere in this test.
do := func(method, path, rangeHeader string) *http.Response {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp
}
resp := do("GET", tokenized, "")
if resp.StatusCode != 200 {
t.Fatalf("tokenized GET: status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != mintedKey {
t.Errorf("served key = %q, want %q (validated key must equal served key)", body, mintedKey)
}
// Seeking works through the same token.
resp = do("GET", tokenized, "bytes=1-3")
if resp.StatusCode != 206 {
t.Errorf("tokenized ranged GET: status = %d, want 206", resp.StatusCode)
}
// The token never opens the JSON subroutes or other objects.
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4%2Fmetadata?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("metadata with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/other.mp4?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("other object with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4", ""); resp.StatusCode != 401 {
t.Errorf("no token, no auth: status = %d, want 401", resp.StatusCode)
}
}
@@ -103,6 +103,30 @@ func TestRoutes_ObjectWildcard_GET_PresignSuffixRoutesToPresigned(t *testing.T)
} }
} }
func TestRoutes_ObjectWildcard_GET_PreviewURLSuffixRoutesToPreviewURL(t *testing.T) {
f := newNoAuthFixture(t)
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/sub/clip.mp4/preview-url", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.PreviewURLResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
// The dispatch trims the /preview-url suffix, so the key becomes sub/clip.mp4,
// percent-encoded whole (slash to %2F) in the returned URL, with a pt token.
if !strings.Contains(body.Data.URL, "/api/v1/buckets/b1/objects/sub%2Fclip.mp4?pt=") {
t.Errorf("url = %q, want the whole-encoded key with a pt token", body.Data.URL)
}
}
func TestRoutes_ObjectWildcard_DELETE_RoutesToDeleteObject(t *testing.T) { func TestRoutes_ObjectWildcard_DELETE_RoutesToDeleteObject(t *testing.T) {
f := newNoAuthFixture(t) f := newNoAuthFixture(t)
+2 -1
View File
@@ -71,7 +71,7 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
svc, svc,
handlers.NewHealthHandler("test"), handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3), handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3), handlers.NewObjectHandler(s3, svc),
handlers.NewUserHandler(admin), handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin), handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3), handlers.NewMonitoringHandler(admin, s3),
@@ -206,6 +206,7 @@ func TestRoutes_AllAPIRoutesRegistered(t *testing.T) {
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt"}, {"GET", "/api/v1/buckets/b1/objects/folder/file.txt"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"}, {"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"}, {"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/preview-url"},
{"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"}, {"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"},
{"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"}, {"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"},
// Users // Users
+1
View File
@@ -52,6 +52,7 @@ type S3Storage interface {
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error)
ObjectExists(ctx context.Context, bucketName, key string) (bool, error) ObjectExists(ctx context.Context, bucketName, key string) (bool, error)
DeleteObject(ctx context.Context, bucketName, key string) error DeleteObject(ctx context.Context, bucketName, key string) error
GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
@@ -28,6 +28,7 @@ type S3Mock struct {
UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
CreateDirectoryMarkerFn func(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) CreateDirectoryMarkerFn func(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
GetObjectRangeFn func(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error)
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error) ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
DeleteObjectFn func(ctx context.Context, bucketName, key string) error DeleteObjectFn func(ctx context.Context, bucketName, key string) error
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
@@ -89,6 +90,14 @@ func (m *S3Mock) GetObject(ctx context.Context, bucketName, key string) (io.Read
return m.GetObjectFn(ctx, bucketName, key) return m.GetObjectFn(ctx, bucketName, key)
} }
func (m *S3Mock) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
m.record("GetObjectRange", bucketName, key)
if m.GetObjectRangeFn == nil {
return nil, s3NotConfigured("GetObjectRange")
}
return m.GetObjectRangeFn(ctx, bucketName, key, start, end)
}
func (m *S3Mock) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) { func (m *S3Mock) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
m.record("ObjectExists", bucketName, key) m.record("ObjectExists", bucketName, key)
if m.ObjectExistsFn == nil { if m.ObjectExistsFn == nil {
+28
View File
@@ -524,6 +524,34 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
return object, objectInfo, nil return object, objectInfo, nil
} }
// GetObjectRange retrieves an inclusive byte range of an object. The caller
// resolves the range against the object size beforehand, so this method does
// not stat the object again.
func (s *S3Service) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
opts := minio.GetObjectOptions{}
if err := opts.SetRange(start, end); err != nil {
return nil, fmt.Errorf("invalid range %d-%d for object %s: %w", start, end, key, err)
}
var object *minio.Object
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = client.GetObject(ctx, bucketName, key, opts)
return getErr
})
if err != nil {
return nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
return object, nil
}
// DeleteObject deletes an object from a bucket // DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error { func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Get bucket-specific MinIO client // Get bucket-specific MinIO client
@@ -669,3 +669,69 @@ func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
} }
} }
} }
func TestGetObjectRange_SendsRangeHeaderAndStreamsBody(t *testing.T) {
bucket := uniqueBucket2(t)
var gotRange string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Range", "bytes 2-6/10")
w.Header().Set("Content-Length", "5")
// The MinIO client parses Last-Modified from the response headers on
// the first Read, so the fake server must set a valid one.
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("23456"))
})
s3 := newS3TestService(t, handler)
body, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 2, 6)
if err != nil {
t.Fatalf("GetObjectRange: %v", err)
}
defer body.Close()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(data) != "23456" {
t.Errorf("body = %q, want %q", data, "23456")
}
if gotRange != "bytes=2-6" {
t.Errorf("Range header = %q, want %q", gotRange, "bytes=2-6")
}
}
func TestGetObjectRange_InvalidRangeRejectedLocally(t *testing.T) {
bucket := uniqueBucket2(t)
var called bool
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
s3 := newS3TestService(t, handler)
// end before start is a caller bug; SetRange rejects it before any request.
if _, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 6, 2); err == nil {
t.Fatal("expected error for inverted range")
}
if called {
t.Error("no S3 request should be sent for an invalid range")
}
}
func TestGetObjectRange_ClientAcquisitionFailurePropagates(t *testing.T) {
bucket := uniqueBucket(t)
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
})
s3, _ := adminBackedS3(t, mux)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := s3.GetObjectRange(ctx, bucket, "missing", 0, 4); err == nil {
t.Fatal("expected error, got nil")
}
}
+1 -1
View File
@@ -153,7 +153,7 @@ func main() {
// Initialize handlers // Initialize handlers
healthHandler := handlers.NewHealthHandler(version) healthHandler := handlers.NewHealthHandler(version)
bucketHandler := handlers.NewBucketHandler(adminService, s3Service) bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
objectHandler := handlers.NewObjectHandler(s3Service) objectHandler := handlers.NewObjectHandler(s3Service, authService)
userHandler := handlers.NewUserHandler(adminService) userHandler := handlers.NewUserHandler(adminService)
clusterHandler := handlers.NewClusterHandler(adminService) clusterHandler := handlers.NewClusterHandler(adminService)
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service) monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
+1
View File
@@ -10,6 +10,7 @@ coverage:
ignore: ignore:
- "backend/main.go" - "backend/main.go"
- "backend/cmd/"
- "backend/docs/" - "backend/docs/"
- "backend/internal/services/mocks/" - "backend/internal/services/mocks/"
- "backend/**/*_mock.go" - "backend/**/*_mock.go"
+1282 -1
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
@@ -18,6 +21,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -33,19 +37,24 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.10",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.12", "postcss": "^8.5.12",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^8.0.16" "vite": "^8.0.16",
"vitest": "^4.1.10"
} }
} }
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { IconTile } from '@/components/ui/icon-tile'; import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ObjectPreview } from '@/components/buckets/ObjectPreview';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react'; import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { downloadObject, formatBytes } from '@/lib/file-utils'; import { downloadObject, formatBytes } from '@/lib/file-utils';
@@ -42,6 +43,7 @@ export function ObjectDetailsView() {
const bucket = buckets.find((b) => b.name === bucketName); const bucket = buckets.find((b) => b.name === bucketName);
const canBucket = useBucketCan(); const canBucket = useBucketCan();
const canDelete = canBucket(bucket, 'object.delete'); const canDelete = canBucket(bucket, 'object.delete');
const canRead = canBucket(bucket, 'object.read');
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null); const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -216,9 +218,19 @@ export function ObjectDetailsView() {
{/* Preview */} {/* Preview */}
<CardSection title="Preview"> <CardSection title="Preview">
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]"> {canRead && bucketName && objectKey ? (
No preview available for this object. <ObjectPreview
</div> bucket={bucketName}
objectKey={objectKey}
size={metadata.size}
contentType={metadata.contentType}
onDownload={handleDownload}
/>
) : (
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
)}
</CardSection> </CardSection>
<ConfirmDialog <ConfirmDialog
@@ -0,0 +1,165 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ObjectPreviewState } from '@/hooks/useObjectPreview';
import { useObjectPreview } from '@/hooks/useObjectPreview';
import { TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
import { ObjectPreview } from './ObjectPreview';
vi.mock('@/hooks/useObjectPreview', () => ({ useObjectPreview: vi.fn() }));
const mockedHook = vi.mocked(useObjectPreview);
function state(overrides: Partial<ObjectPreviewState>): ObjectPreviewState {
return {
kind: 'none',
status: 'unsupported',
objectUrl: null,
text: null,
mediaUrl: null,
retry: vi.fn(),
onMediaError: vi.fn(),
...overrides,
};
}
function renderPreview() {
// A .json key keeps the highlight language deterministic in the text test.
return render(
<ObjectPreview bucket="b" objectKey="k.json" size={100} contentType="text/plain" onDownload={vi.fn()} />,
);
}
afterEach(() => {
vi.clearAllMocks();
});
describe('ObjectPreview', () => {
it('shows the loading state', () => {
mockedHook.mockReturnValue(state({ kind: 'image', status: 'loading' }));
renderPreview();
expect(screen.getByText(/loading preview/i)).toBeInTheDocument();
});
it('renders an image from the object url', () => {
mockedHook.mockReturnValue(state({ kind: 'image', status: 'ready', objectUrl: 'blob:img' }));
renderPreview();
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:img');
});
it('renders video with the media url', () => {
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
const video = container.querySelector('video');
expect(video).not.toBeNull();
expect(video).toHaveAttribute('src', '/u?pt=t');
expect(video).not.toHaveAttribute('autoplay');
});
it('renders audio with the media url', () => {
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
expect(container.querySelector('audio')).toHaveAttribute('src', '/u?pt=t');
});
it('renders pdf in an iframe', () => {
mockedHook.mockReturnValue(state({ kind: 'pdf', status: 'ready', objectUrl: 'blob:pdf' }));
renderPreview();
expect(screen.getByTitle('k.json')).toHaveAttribute('src', 'blob:pdf');
});
it('renders plain text immediately and highlighted text after the import resolves', async () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
const { container } = renderPreview();
expect(container.querySelector('pre')).toHaveTextContent('{"a": 1}');
await waitFor(() => expect(container.querySelector('code [class*="hljs-"]')).not.toBeNull());
});
it('shows the too-large notice with a download action', () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'too-large' }));
renderPreview();
expect(screen.getByText(/too large to preview/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument();
});
it('shows the unsupported notice', () => {
mockedHook.mockReturnValue(state({ kind: 'none', status: 'unsupported' }));
renderPreview();
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
});
it('shows the binary notice', () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'binary' }));
renderPreview();
expect(screen.getByText(/doesn't appear to be text/i)).toBeInTheDocument();
});
it('shows the error state with retry wired to the hook', () => {
const retry = vi.fn();
mockedHook.mockReturnValue(state({ kind: 'image', status: 'error', retry }));
renderPreview();
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
expect(retry).toHaveBeenCalled();
});
// The tests below are additional to the brief's list. They were added to
// close coverage gaps (media error and resume handling, the highlight size
// guard, and the defensive fallback branch) found while verifying the
// 90 percent coverage requirement on this file.
it('skips highlighting for text over the highlight size limit', () => {
const bigText = 'a'.repeat(TEXT_HIGHLIGHT_MAX_BYTES + 1);
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: bigText }));
const { container } = renderPreview();
expect(container.querySelector('code [class*="hljs-"]')).toBeNull();
expect(container.querySelector('code')?.textContent).toHaveLength(bigText.length);
});
it('unmounting before the highlight import resolves is safe', async () => {
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
const { container, unmount } = renderPreview();
expect(() => unmount()).not.toThrow();
expect(container.innerHTML).toBe('');
// Let the pending dynamic import resolve after unmount. The cancelled
// guard means the resolved callback renders nothing back into the
// detached container, so it stays empty and no error is thrown.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(container.innerHTML).toBe('');
});
it('resumes the playback position after a media error and reload', () => {
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
const video = container.querySelector('video')!;
// Loading metadata with no prior error is a no-op (resume position is 0).
fireEvent.loadedMetadata(video);
Object.defineProperty(video, 'currentTime', { value: 42, writable: true, configurable: true });
fireEvent.error(video);
Object.defineProperty(video, 'currentTime', { value: 0, writable: true, configurable: true });
fireEvent.loadedMetadata(video);
expect(video.currentTime).toBe(42);
});
it('does not restore a position when the media element reported no currentTime', () => {
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
const { container } = renderPreview();
const audio = container.querySelector('audio')!;
// The element reports no currentTime, so the captured resume position
// falls back to 0 via the ?? 0 guard, which keeps the restore guard
// (resumeAtRef.current > 0) false. A later loadedmetadata must then leave
// the position untouched, unlike the sibling resume test above.
Object.defineProperty(audio, 'currentTime', { value: undefined, writable: true, configurable: true });
fireEvent.error(audio);
Object.defineProperty(audio, 'currentTime', { value: 99, writable: true, configurable: true });
fireEvent.loadedMetadata(audio);
expect(audio.currentTime).toBe(99);
});
it('falls back to the generic notice for an unhandled preview kind', () => {
mockedHook.mockReturnValue(state({ kind: 'none', status: 'ready' }));
renderPreview();
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,159 @@
import { useEffect, useRef, useState } from 'react';
import { Download, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useObjectPreview } from '@/hooks/useObjectPreview';
import { getHighlightLanguage, TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
import { formatBytes } from '@/lib/file-utils';
function Notice({
message,
onDownload,
onRetry,
}: {
message: string;
onDownload?: () => void;
onRetry?: () => void;
}) {
return (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
<p>{message}</p>
{(onRetry || onDownload) && (
<div className="flex gap-2">
{onRetry && (
<Button variant="secondary" onClick={onRetry}>
<RefreshCw className="h-4 w-4" /> Retry
</Button>
)}
{onDownload && (
<Button variant="secondary" onClick={onDownload}>
<Download className="h-4 w-4" /> Download
</Button>
)}
</div>
)}
</div>
);
}
function CodeBlock({ text, objectKey }: { text: string; objectKey: string }) {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
// Highlighting is progressive enhancement. Large files and any import or
// highlight failure fall back to the plain text already on screen.
if (text.length > TEXT_HIGHLIGHT_MAX_BYTES) return;
let cancelled = false;
import('@/lib/highlight')
.then(({ highlight }) => {
if (!cancelled) setHtml(highlight(text, getHighlightLanguage(objectKey)));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [text, objectKey]);
return (
<pre className="overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-relaxed">
{html !== null ? <code dangerouslySetInnerHTML={{ __html: html }} /> : <code>{text}</code>}
</pre>
);
}
export function ObjectPreview({
bucket,
objectKey,
size,
contentType,
onDownload,
}: {
bucket: string;
objectKey: string;
size: number;
contentType?: string;
onDownload: () => void;
}) {
const preview = useObjectPreview(bucket, objectKey, size, contentType);
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
const resumeAtRef = useRef(0);
const handleMediaError = () => {
resumeAtRef.current = mediaRef.current?.currentTime ?? 0;
preview.onMediaError();
};
const handleLoadedMetadata = () => {
if (resumeAtRef.current > 0 && mediaRef.current) {
mediaRef.current.currentTime = resumeAtRef.current;
resumeAtRef.current = 0;
}
};
switch (preview.status) {
case 'unsupported':
return <Notice message="No preview available for this object." onDownload={onDownload} />;
case 'too-large':
return (
<Notice
message={`File is too large to preview (${formatBytes(size)}), download it instead.`}
onDownload={onDownload}
/>
);
case 'binary':
return <Notice message="This file doesn't appear to be text." onDownload={onDownload} />;
case 'error':
return <Notice message="Could not load the preview." onRetry={preview.retry} onDownload={onDownload} />;
case 'loading':
return (
<div className="flex items-center justify-center gap-2 px-5 py-10 text-[13px] text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" /> Loading preview
</div>
);
}
switch (preview.kind) {
case 'image':
return (
<div className="flex justify-center bg-[var(--surface-sunken)] px-5 py-6">
<img src={preview.objectUrl!} alt={objectKey} className="h-auto max-w-full object-contain" />
</div>
);
case 'video':
return (
<div className="flex justify-center bg-black">
<video
ref={(el) => {
mediaRef.current = el;
}}
controls
preload="metadata"
src={preview.mediaUrl!}
onError={handleMediaError}
onLoadedMetadata={handleLoadedMetadata}
className="max-h-[85vh] w-full"
/>
</div>
);
case 'audio':
return (
<div className="px-5 py-6">
<audio
ref={(el) => {
mediaRef.current = el;
}}
controls
src={preview.mediaUrl!}
onError={handleMediaError}
onLoadedMetadata={handleLoadedMetadata}
className="w-full"
/>
</div>
);
case 'pdf':
return <iframe src={preview.objectUrl!} title={objectKey} className="h-[85vh] w-full" />;
case 'text':
return <CodeBlock text={preview.text!} objectKey={objectKey} />;
default:
return <Notice message="No preview available for this object." onDownload={onDownload} />;
}
}
@@ -0,0 +1,160 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { objectsApi } from '@/lib/api';
import { useObjectPreview } from './useObjectPreview';
vi.mock('@/lib/api', () => ({
objectsApi: {
get: vi.fn(),
getPreviewUrl: vi.fn(),
},
}));
const mockedGet = vi.mocked(objectsApi.get);
const mockedGetPreviewUrl = vi.mocked(objectsApi.getPreviewUrl);
beforeAll(() => {
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
// One client per test, created outside the component so rerenders reuse it.
function createWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe('useObjectPreview', () => {
it('fetches a blob and produces an object url for images', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])]));
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.kind).toBe('image');
expect(result.current.objectUrl).toBe('blob:mock-url');
expect(mockedGet).toHaveBeenCalledWith('b', 'pic.png');
});
it('decodes text content', async () => {
mockedGet.mockResolvedValue(new Blob(['{"a": 1}']));
const { result } = renderHook(() => useObjectPreview('b', 'data.json', 8, 'application/json'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.text).toBe('{"a": 1}');
});
it('reports binary content pretending to be text', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([0, 1, 2, 3, 4, 0, 1, 2, 3, 4])]));
const { result } = renderHook(() => useObjectPreview('b', 'weird.log', 10, undefined), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('binary'));
});
it('does not fetch when the object is over the limit', () => {
const { result } = renderHook(
() => useObjectPreview('b', 'big.txt', 6 * 1024 * 1024, 'text/plain'),
{ wrapper: createWrapper() },
);
expect(result.current.status).toBe('too-large');
expect(mockedGet).not.toHaveBeenCalled();
});
it('reports unsupported kinds without fetching', () => {
const { result } = renderHook(() => useObjectPreview('b', 'blob.bin', 10, undefined), { wrapper: createWrapper() });
expect(result.current.status).toBe('unsupported');
expect(mockedGet).not.toHaveBeenCalled();
});
it('mints a media url for video without fetching bytes', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/api/v1/buckets/b/objects/v.mp4?pt=tok', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10_000_000_000, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.mediaUrl).toBe('/api/v1/buckets/b/objects/v.mp4?pt=tok');
expect(mockedGet).not.toHaveBeenCalled();
});
it('re-mints once on media error, then reports error', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
expect(result.current.status).toBe('ready');
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('error'));
});
it('reports fetch failures and recovers on retry', async () => {
mockedGet.mockRejectedValueOnce(new Error('network down'));
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('error'));
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
});
it('revokes the object url on unmount', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
const { result, unmount } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
unmount();
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('recovers when the target object changes after a media error', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/a?pt=1', expiresAt: 'later' });
const { result, rerender } = renderHook(
({ k }) => useObjectPreview('b', k, 10, 'video/mp4'),
{ wrapper: createWrapper(), initialProps: { k: 'a.mp4' } },
);
await waitFor(() => expect(result.current.status).toBe('ready'));
// Exhaust the single re-mint on object A, driving it into the error state.
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('error'));
// Switch to object B on the same hook instance. The stale error from A
// must not leave B stuck: it should load and reach ready.
mockedGetPreviewUrl.mockResolvedValue({ url: '/b?pt=1', expiresAt: 'later' });
rerender({ k: 'b.mp4' });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.mediaUrl).toBe('/b?pt=1');
});
it('refetches the media url on retry', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
act(() => result.current.retry());
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
expect(result.current.status).toBe('ready');
});
it('ignores a pending text decode after unmount', async () => {
let resolveText: (value: string) => void = () => {};
const blob = new Blob(['hello']);
vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
mockedGet.mockResolvedValue(blob);
const { result, unmount } = renderHook(() => useObjectPreview('b', 'a.txt', 5, 'text/plain'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.objectUrl).toBe('blob:mock-url'));
// Unmount before the decode resolves, then resolve it. The cancelled
// guard must swallow the late result rather than set state.
unmount();
act(() => resolveText('hello'));
expect(result.current.text).toBeNull();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { objectsApi } from '@/lib/api';
import {
getPreviewKind,
getPreviewMime,
getPreviewSizeLimit,
looksBinary,
type PreviewKind,
} from '@/lib/preview-utils';
export interface ObjectPreviewState {
kind: PreviewKind;
status: 'loading' | 'ready' | 'too-large' | 'unsupported' | 'binary' | 'error';
objectUrl: string | null;
text: string | null;
mediaUrl: string | null;
retry: () => void;
onMediaError: () => void;
}
// Documents (image, pdf, text) are fetched as blobs through the normal
// authenticated API path. Media (video, audio) gets a short-lived tokenized
// URL instead, because media elements cannot send an Authorization header
// and must stream with Range requests rather than load fully.
export function useObjectPreview(
bucket: string,
objectKey: string,
size: number,
contentType?: string,
): ObjectPreviewState {
const kind = getPreviewKind(contentType, objectKey);
const sizeLimit = getPreviewSizeLimit(kind);
const isDocument = kind === 'image' || kind === 'pdf' || kind === 'text';
const isMedia = kind === 'video' || kind === 'audio';
const tooLarge = isDocument && sizeLimit !== null && size > sizeLimit;
const blobQuery = useQuery({
queryKey: ['object-preview', bucket, objectKey],
queryFn: () => objectsApi.get(bucket, objectKey),
enabled: isDocument && !tooLarge,
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const urlQuery = useQuery({
queryKey: ['object-preview-url', bucket, objectKey],
queryFn: () => objectsApi.getPreviewUrl(bucket, objectKey),
enabled: isMedia,
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [text, setText] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const [mediaFailed, setMediaFailed] = useState(false);
const remintedRef = useRef(false);
// Reset media error state when the target object changes, so a failure on
// one object does not leave a later object stuck in the error state. The
// hook instance is reused across navigation, it does not remount.
useEffect(() => {
setMediaFailed(false);
remintedRef.current = false;
}, [bucket, objectKey]);
useEffect(() => {
const blob = blobQuery.data;
if (!blob) return;
let cancelled = false;
const typed = new Blob([blob], { type: getPreviewMime(kind, contentType, objectKey) });
const url = URL.createObjectURL(typed);
setObjectUrl(url);
if (kind === 'text') {
blob.text().then((decoded) => {
if (cancelled) return;
if (looksBinary(decoded.slice(0, 4096))) setIsBinary(true);
else setText(decoded);
});
}
return () => {
cancelled = true;
URL.revokeObjectURL(url);
setObjectUrl(null);
setText(null);
setIsBinary(false);
};
}, [blobQuery.data, kind, contentType, objectKey]);
const onMediaError = () => {
if (remintedRef.current) {
setMediaFailed(true);
return;
}
remintedRef.current = true;
urlQuery.refetch();
};
const retry = () => {
remintedRef.current = false;
setMediaFailed(false);
if (isDocument) blobQuery.refetch();
if (isMedia) urlQuery.refetch();
};
let status: ObjectPreviewState['status'];
if (kind === 'none') status = 'unsupported';
else if (tooLarge) status = 'too-large';
else if (isBinary) status = 'binary';
else if (mediaFailed || blobQuery.isError || urlQuery.isError) status = 'error';
else if (isMedia) status = urlQuery.data ? 'ready' : 'loading';
else if (kind === 'text') status = text !== null ? 'ready' : 'loading';
else status = objectUrl ? 'ready' : 'loading';
return {
kind,
status,
objectUrl,
text,
mediaUrl: urlQuery.data?.url ?? null,
retry,
onMediaError,
};
}
+74
View File
@@ -161,3 +161,77 @@
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent); background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
} }
} }
/* Syntax highlighting tokens for the object preview. The dark overrides ride
the same root class the theme provider toggles. */
.hljs-comment,
.hljs-quote {
color: #6a737d;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-subst {
color: #d73a49;
}
.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable {
color: #005cc5;
}
.hljs-string,
.hljs-doctag,
.hljs-regexp {
color: #032f62;
}
.hljs-title,
.hljs-section,
.hljs-name {
color: #6f42c1;
}
.hljs-attr,
.hljs-attribute,
.hljs-built_in {
color: #005cc5;
}
.hljs-meta,
.hljs-selector-id,
.hljs-selector-class {
color: #e36209;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #8b949e;
}
.dark .hljs-keyword,
.dark .hljs-selector-tag,
.dark .hljs-subst {
color: #ff7b72;
}
.dark .hljs-number,
.dark .hljs-literal,
.dark .hljs-variable,
.dark .hljs-template-variable {
color: #79c0ff;
}
.dark .hljs-string,
.dark .hljs-doctag,
.dark .hljs-regexp {
color: #a5d6ff;
}
.dark .hljs-title,
.dark .hljs-section,
.dark .hljs-name {
color: #d2a8ff;
}
.dark .hljs-attr,
.dark .hljs-attribute,
.dark .hljs-built_in {
color: #79c0ff;
}
.dark .hljs-meta,
.dark .hljs-selector-id,
.dark .hljs-selector-class {
color: #ffa657;
}
+6
View File
@@ -395,6 +395,12 @@ export const objectsApi = {
}); });
return response.data.data.url; return response.data.data.url;
}, },
getPreviewUrl: async (bucket: string, key: string): Promise<{ url: string; expiresAt: string }> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/preview-url`);
const data = response.data.data;
return { url: data.url, expiresAt: data.expires_at };
},
}; };
// Access Control API (Users/Keys) // Access Control API (Users/Keys)
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { highlight } from './highlight';
describe('highlight', () => {
it('highlights a known language', () => {
const html = highlight('{"a": 1}', 'json');
expect(html).toContain('hljs-');
});
it('escapes html in the source', () => {
const html = highlight('<script>alert(1)</script>', 'xml');
expect(html).not.toContain('<script>');
});
it('falls back to auto detection for null language', () => {
const html = highlight('SELECT * FROM t;', null);
expect(typeof html).toBe('string');
expect(html.length).toBeGreaterThan(0);
});
it('falls back to auto detection for an unregistered language', () => {
const html = highlight('plain words', 'klingon');
expect(typeof html).toBe('string');
});
});
+39
View File
@@ -0,0 +1,39 @@
// Loaded only via dynamic import() from the text preview renderer, so
// highlight.js never enters the main bundle.
import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
import css from 'highlight.js/lib/languages/css';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
import go from 'highlight.js/lib/languages/go';
import ini from 'highlight.js/lib/languages/ini';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import markdown from 'highlight.js/lib/languages/markdown';
import python from 'highlight.js/lib/languages/python';
import sql from 'highlight.js/lib/languages/sql';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('css', css);
hljs.registerLanguage('dockerfile', dockerfile);
hljs.registerLanguage('go', go);
hljs.registerLanguage('ini', ini);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('json', json);
hljs.registerLanguage('markdown', markdown);
hljs.registerLanguage('python', python);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('yaml', yaml);
// Returns highlighted HTML for the source text. hljs escapes the input, so
// the output is safe to assign as innerHTML.
export function highlight(text: string, language: string | null): string {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(text, { language }).value;
}
return hljs.highlightAuto(text).value;
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
getHighlightLanguage,
getPreviewKind,
getPreviewMime,
getPreviewSizeLimit,
looksBinary,
IMAGE_PREVIEW_MAX_BYTES,
PDF_PREVIEW_MAX_BYTES,
TEXT_PREVIEW_MAX_BYTES,
} from './preview-utils';
describe('getPreviewKind', () => {
it('trusts a specific content type first', () => {
expect(getPreviewKind('image/png', 'noext')).toBe('image');
expect(getPreviewKind('video/mp4', 'noext')).toBe('video');
expect(getPreviewKind('audio/mpeg', 'noext')).toBe('audio');
expect(getPreviewKind('application/pdf', 'noext')).toBe('pdf');
expect(getPreviewKind('text/plain', 'noext')).toBe('text');
expect(getPreviewKind('application/json', 'noext')).toBe('text');
});
it('ignores content type parameters', () => {
expect(getPreviewKind('text/plain; charset=utf-8', 'noext')).toBe('text');
});
it('falls back to the extension when the type is octet-stream', () => {
expect(getPreviewKind('application/octet-stream', 'photo.JPG')).toBe('image');
expect(getPreviewKind('application/octet-stream', 'clip.mp4')).toBe('video');
expect(getPreviewKind('application/octet-stream', 'song.flac')).toBe('audio');
expect(getPreviewKind('application/octet-stream', 'doc.pdf')).toBe('pdf');
expect(getPreviewKind('application/octet-stream', 'conf.yaml')).toBe('text');
expect(getPreviewKind(undefined, 'a/b/c.json')).toBe('text');
});
it('detects svg as image even though the backend rewrites its type', () => {
expect(getPreviewKind('application/octet-stream', 'logo.svg')).toBe('image');
});
it('detects Dockerfile without an extension', () => {
expect(getPreviewKind(undefined, 'build/Dockerfile')).toBe('text');
});
it('returns none for unknown files', () => {
expect(getPreviewKind('application/octet-stream', 'blob.bin')).toBe('none');
expect(getPreviewKind(undefined, 'noext')).toBe('none');
expect(getPreviewKind(undefined, 'archive.zip')).toBe('none');
});
it('does not treat a dotfile name as an extension', () => {
expect(getPreviewKind(undefined, '.gitignore')).toBe('none');
});
});
describe('getPreviewSizeLimit', () => {
it('caps documents per kind and leaves media unlimited', () => {
expect(getPreviewSizeLimit('text')).toBe(TEXT_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('image')).toBe(IMAGE_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('pdf')).toBe(PDF_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('video')).toBeNull();
expect(getPreviewSizeLimit('audio')).toBeNull();
expect(getPreviewSizeLimit('none')).toBe(0);
});
});
describe('getPreviewMime', () => {
it('restores the mime type the backend rewrote', () => {
expect(getPreviewMime('image', 'application/octet-stream', 'logo.svg')).toBe('image/svg+xml');
expect(getPreviewMime('pdf', 'application/octet-stream', 'doc.pdf')).toBe('application/pdf');
});
it('keeps a usable content type when there is no mapping', () => {
expect(getPreviewMime('text', 'text/plain', 'readme.txt')).toBe('text/plain');
expect(getPreviewMime('text', undefined, 'readme.weird')).toBe('application/octet-stream');
});
});
describe('getHighlightLanguage', () => {
it('maps common extensions', () => {
expect(getHighlightLanguage('a.json')).toBe('json');
expect(getHighlightLanguage('a.yml')).toBe('yaml');
expect(getHighlightLanguage('a.tsx')).toBe('typescript');
expect(getHighlightLanguage('Dockerfile')).toBe('dockerfile');
});
it('returns null for unmapped extensions', () => {
expect(getHighlightLanguage('a.log')).toBeNull();
});
});
describe('looksBinary', () => {
it('accepts ordinary text with newlines and tabs', () => {
expect(looksBinary('hello\n\tworld\r\n')).toBe(false);
expect(looksBinary('')).toBe(false);
});
it('flags replacement-heavy content', () => {
expect(looksBinary('ab')).toBe(true);
});
it('flags control-character-heavy content', () => {
expect(looksBinary('\x00\x01\x02abc')).toBe(true);
});
it('tolerates a small fraction of oddities', () => {
expect(looksBinary('a'.repeat(99) + '\x00')).toBe(false);
});
});
+126
View File
@@ -0,0 +1,126 @@
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'none';
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
export const TEXT_HIGHLIGHT_MAX_BYTES = 1 * 1024 * 1024;
export const IMAGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
export const PDF_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
const imageMimeByExtension: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
svg: 'image/svg+xml',
};
const videoExtensions = new Set(['mp4', 'webm', 'ogv', 'mov']);
const audioExtensions = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
const textExtensions = new Set([
'json', 'yaml', 'yml', 'ini', 'toml', 'xml', 'csv', 'md', 'log', 'txt',
'conf', 'cfg', 'env', 'sh', 'bash', 'py', 'js', 'ts', 'jsx', 'tsx',
'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'sql', 'html', 'css', 'dockerfile',
]);
function getExtension(key: string): string {
const name = key.split('/').pop() ?? '';
if (name.toLowerCase() === 'dockerfile') return 'dockerfile';
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
// Content-Type first, extension fallback. The fallback matters: Garage often
// stores application/octet-stream, and the backend rewrites unsafe types to
// it, so a generic type defers to the file extension.
export function getPreviewKind(contentType: string | undefined, key: string): PreviewKind {
const ct = (contentType ?? '').split(';')[0].trim().toLowerCase();
if (ct && ct !== 'application/octet-stream') {
if (ct.startsWith('image/')) return 'image';
if (ct.startsWith('video/')) return 'video';
if (ct.startsWith('audio/')) return 'audio';
if (ct === 'application/pdf') return 'pdf';
if (ct.startsWith('text/') || ct === 'application/json') return 'text';
}
const ext = getExtension(key);
if (ext in imageMimeByExtension) return 'image';
if (videoExtensions.has(ext)) return 'video';
if (audioExtensions.has(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
if (textExtensions.has(ext)) return 'text';
return 'none';
}
// Byte limit per kind. null means no limit because media streams with Range
// requests and is never fully loaded into memory.
export function getPreviewSizeLimit(kind: PreviewKind): number | null {
switch (kind) {
case 'text':
return TEXT_PREVIEW_MAX_BYTES;
case 'image':
return IMAGE_PREVIEW_MAX_BYTES;
case 'pdf':
return PDF_PREVIEW_MAX_BYTES;
case 'video':
case 'audio':
return null;
case 'none':
return 0;
}
}
// The backend rewrites unsafe Content-Types to application/octet-stream, so
// blob URLs need their MIME type restored for the SVG <img> case and the
// browser PDF viewer.
export function getPreviewMime(kind: PreviewKind, contentType: string | undefined, key: string): string {
if (kind === 'image') {
return imageMimeByExtension[getExtension(key)] ?? contentType ?? 'application/octet-stream';
}
if (kind === 'pdf') return 'application/pdf';
return contentType || 'application/octet-stream';
}
const languageByExtension: Record<string, string> = {
json: 'json',
yaml: 'yaml',
yml: 'yaml',
ini: 'ini',
toml: 'ini',
xml: 'xml',
md: 'markdown',
sh: 'bash',
bash: 'bash',
py: 'python',
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
go: 'go',
sql: 'sql',
html: 'xml',
css: 'css',
dockerfile: 'dockerfile',
};
export function getHighlightLanguage(key: string): string | null {
return languageByExtension[getExtension(key)] ?? null;
}
// A text preview only makes sense for content that decodes as text. More
// than 10 percent replacement or non-whitespace control characters in the
// sample means the file is binary despite its name.
export function looksBinary(sample: string): boolean {
if (sample.length === 0) return false;
let suspicious = 0;
let total = 0;
for (const ch of sample) {
total++;
const code = ch.codePointAt(0) ?? 0;
if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
suspicious++;
}
}
return suspicious / total > 0.1;
}
+10
View File
@@ -0,0 +1,10 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// Testing Library's automatic cleanup only self-registers when a global
// afterEach exists. This harness does not set test.globals, so register it
// explicitly to unmount rendered trees between tests.
afterEach(() => {
cleanup();
});
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
include: [
'src/lib/preview-utils.ts',
'src/lib/highlight.ts',
'src/hooks/useObjectPreview.ts',
'src/components/buckets/ObjectPreview.tsx',
],
thresholds: { statements: 90, branches: 90, functions: 90, lines: 90 },
},
},
});