diff --git a/.gitignore b/.gitignore index 02c3824..c29e23d 100644 --- a/.gitignore +++ b/.gitignore @@ -66,5 +66,7 @@ config.yaml docs/**/*.md !docs/garage-setup.md !docs/access-control.md +**/state** +**/content** **/worktrees \ No newline at end of file diff --git a/README.md b/README.md index 9753462..4f19fd3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ A modern web interface to manage Garag - **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) - **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 @@ -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] **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 -- [ ] **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 - [ ] **Resumable uploads**: multipart uploads that survive a dropped connection - [ ] **Visual layout editor**: staged vs. applied diff before committing layout changes diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..8e1ef17 --- /dev/null +++ b/backend/cmd/seed/main.go @@ -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= and any re-uploaded key simply overwrites. +// Layout: pets///-.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) + } +} diff --git a/backend/internal/auth/preview.go b/backend/internal/auth/preview.go new file mode 100644 index 0000000..2feddc4 --- /dev/null +++ b/backend/internal/auth/preview.go @@ -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)) +} diff --git a/backend/internal/auth/preview_test.go b/backend/internal/auth/preview_test.go new file mode 100644 index 0000000..3ba9e51 --- /dev/null +++ b/backend/internal/auth/preview_test.go @@ -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") + } +} diff --git a/backend/internal/authz/middleware.go b/backend/internal/authz/middleware.go index aa39521..30f6a42 100644 --- a/backend/internal/authz/middleware.go +++ b/backend/internal/authz/middleware.go @@ -103,6 +103,15 @@ func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler if !m.enabled { 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) if !ok { logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject") diff --git a/backend/internal/authz/middleware_test.go b/backend/internal/authz/middleware_test.go index a9fb46e..656d0bb 100644 --- a/backend/internal/authz/middleware_test.go +++ b/backend/internal/authz/middleware_test.go @@ -217,6 +217,56 @@ func TestRequireZeroPermissionsPanics(t *testing.T) { 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) { // 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 diff --git a/backend/internal/handlers/objects.go b/backend/internal/handlers/objects.go index 56bd2c7..50f3d10 100644 --- a/backend/internal/handlers/objects.go +++ b/backend/internal/handlers/objects.go @@ -1,7 +1,6 @@ package handlers import ( - "bufio" "io" "net/url" "path" @@ -68,15 +67,27 @@ func contentDispositionHeader(disposition, key string) string { 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. type ObjectHandler struct { - s3Service services.S3Storage + s3Service services.S3Storage + previewTokens PreviewTokenMinter } // NewObjectHandler creates a new object handler. -func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler { +func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMinter) *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" // @Router /api/v1/buckets/{bucket}/objects/{key} [get] func (h *ObjectHandler) GetObject(c fiber.Ctx) error { - ctx := c.Context() - - // Get bucket name from URL parameters bucketName := c.Params("bucket") - // Get object key from locals (set by route handler) or from params key, ok := c.Locals("objectKey").(string) if !ok || 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) if err != nil { 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. c.Set("Content-Type", safeContentType(objectInfo.ContentType)) c.Set("X-Content-Type-Options", "nosniff") + c.Set("Accept-Ranges", "bytes") c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10)) c.Set("ETag", objectInfo.ETag) 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=. disposition := "inline" if c.Query("download") == "true" { @@ -319,11 +337,60 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error { } c.Set("Content-Disposition", contentDispositionHeader(disposition, key)) - // Stream the object body to the client without buffering the entire file - return c.SendStreamWriter(func(w *bufio.Writer) { - defer body.Close() - io.Copy(w, body) - }) + // 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(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 @@ -428,6 +495,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error { ) } + c.Set("Accept-Ranges", "bytes") return c.JSON(models.SuccessResponse(metadata)) } @@ -512,6 +580,48 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error { 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 // // @Summary Delete multiple objects from bucket diff --git a/backend/internal/handlers/objects_test.go b/backend/internal/handlers/objects_test.go index 00b72f8..546da1e 100644 --- a/backend/internal/handlers/objects_test.go +++ b/backend/internal/handlers/objects_test.go @@ -20,23 +20,42 @@ import ( "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) { + app, s3, _ := newObjectsTestAppWithMinter(t) + return app, s3 +} + +func newObjectsTestAppWithMinter(t *testing.T) (*fiber.App, *mocks.S3Mock, *mintStub) { t.Helper() s3 := &mocks.S3Mock{} - h := NewObjectHandler(s3) + minter := &mintStub{} + h := NewObjectHandler(s3, minter) app := fiber.New() app.Get("/buckets/:bucket/objects", h.ListObjects) app.Post("/buckets/:bucket/objects", h.UploadObject) app.Post("/buckets/:bucket/directories", h.CreateDirectory) app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects) 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. app.Get("/buckets/:bucket/objects/:key", h.GetObject) app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata) 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) - return app, s3 + return app, s3, minter } // --- 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 --- 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) } } + +// --- 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) + } +} diff --git a/backend/internal/handlers/range.go b/backend/internal/handlers/range.go new file mode 100644 index 0000000..87fcc98 --- /dev/null +++ b/backend/internal/handlers/range.go @@ -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 +} diff --git a/backend/internal/handlers/range_test.go b/backend/internal/handlers/range_test.go new file mode 100644 index 0000000..cc62a2c --- /dev/null +++ b/backend/internal/handlers/range_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index c3343d8..247b704 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "net/url" "strings" "Noooste/garage-ui/internal/auth" @@ -23,6 +24,31 @@ func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Han 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") // 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, "+") } + +// previewRouteParts extracts the bucket and raw (still percent-encoded) +// object key from a request path shaped like +// "/api/v1/buckets//objects/", 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 +} diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go index b4c94bd..f7aa1d1 100644 --- a/backend/internal/middleware/auth_test.go +++ b/backend/internal/middleware/auth_test.go @@ -3,10 +3,13 @@ package middleware import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" + "time" "Noooste/garage-ui/internal/auth" "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"]) } } + +// 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) + } + }) + } +} diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go index 57c8572..358f997 100644 --- a/backend/internal/models/responses.go +++ b/backend/internal/models/responses.go @@ -150,6 +150,11 @@ type PresignedURLResponse struct { Key string `json:"key"` } +type PreviewURLResponse struct { + URL string `json:"url"` + ExpiresAt string `json:"expires_at"` +} + type ObjectDeleteMultipleResponse struct { Bucket string `json:"bucket"` Deleted int `json:"deleted"` diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 6553a1f..e6664d1 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -113,6 +113,9 @@ func SetupRoutes( case strings.HasSuffix(path, "/presign"): c.Locals("objectKey", strings.TrimSuffix(path, "/presign")) return objectHandler.GetPresignedURL(c) + case strings.HasSuffix(path, "/preview-url"): + c.Locals("objectKey", strings.TrimSuffix(path, "/preview-url")) + return objectHandler.GetPreviewURL(c) default: c.Locals("objectKey", path) return objectHandler.GetObject(c) diff --git a/backend/internal/routes/routes_authz_test.go b/backend/internal/routes/routes_authz_test.go index 5b75c46..687d31e 100644 --- a/backend/internal/routes/routes_authz_test.go +++ b/backend/internal/routes/routes_authz_test.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "io" + "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -100,7 +102,7 @@ func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) { svc, handlers.NewHealthHandler("test"), handlers.NewBucketHandler(admin, s3), - handlers.NewObjectHandler(s3), + handlers.NewObjectHandler(s3, svc), handlers.NewUserHandler(admin), handlers.NewClusterHandler(admin), 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") } } + +// 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) + } +} diff --git a/backend/internal/routes/routes_more_test.go b/backend/internal/routes/routes_more_test.go index 7d28f31..aa26aee 100644 --- a/backend/internal/routes/routes_more_test.go +++ b/backend/internal/routes/routes_more_test.go @@ -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) { f := newNoAuthFixture(t) diff --git a/backend/internal/routes/routes_test.go b/backend/internal/routes/routes_test.go index fa250e5..07f441f 100644 --- a/backend/internal/routes/routes_test.go +++ b/backend/internal/routes/routes_test.go @@ -71,7 +71,7 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture { svc, handlers.NewHealthHandler("test"), handlers.NewBucketHandler(admin, s3), - handlers.NewObjectHandler(s3), + handlers.NewObjectHandler(s3, svc), handlers.NewUserHandler(admin), handlers.NewClusterHandler(admin), 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/metadata"}, {"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"}, {"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"}, // Users diff --git a/backend/internal/services/interfaces.go b/backend/internal/services/interfaces.go index 8795204..cd2b554 100644 --- a/backend/internal/services/interfaces.go +++ b/backend/internal/services/interfaces.go @@ -52,6 +52,7 @@ type S3Storage interface { 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) 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) DeleteObject(ctx context.Context, bucketName, key string) error GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) diff --git a/backend/internal/services/mocks/s3_mock.go b/backend/internal/services/mocks/s3_mock.go index ce640ac..91ec0a8 100644 --- a/backend/internal/services/mocks/s3_mock.go +++ b/backend/internal/services/mocks/s3_mock.go @@ -28,6 +28,7 @@ type S3Mock struct { 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) 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) DeleteObjectFn func(ctx context.Context, bucketName, key string) 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) } +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) { m.record("ObjectExists", bucketName, key) if m.ObjectExistsFn == nil { diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index cc0a569..641f104 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -524,6 +524,34 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R 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 func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error { // Get bucket-specific MinIO client diff --git a/backend/internal/services/s3_minio_test.go b/backend/internal/services/s3_minio_test.go index 361aed3..d91fd55 100644 --- a/backend/internal/services/s3_minio_test.go +++ b/backend/internal/services/s3_minio_test.go @@ -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") + } +} diff --git a/backend/main.go b/backend/main.go index daa1458..39b9a7c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -153,7 +153,7 @@ func main() { // Initialize handlers healthHandler := handlers.NewHealthHandler(version) bucketHandler := handlers.NewBucketHandler(adminService, s3Service) - objectHandler := handlers.NewObjectHandler(s3Service) + objectHandler := handlers.NewObjectHandler(s3Service, authService) userHandler := handlers.NewUserHandler(adminService) clusterHandler := handlers.NewClusterHandler(adminService) monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service) diff --git a/codecov.yml b/codecov.yml index d940eae..871de3d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,6 +10,7 @@ coverage: ignore: - "backend/main.go" + - "backend/cmd/" - "backend/docs/" - "backend/internal/services/mocks/" - "backend/**/*_mock.go" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e421e73..4f18cae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "highlight.js": "^11.11.1", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -31,22 +32,34 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@tailwindcss/postcss": "^4.1.17", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", + "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.22", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.12", "tailwindcss": "^4.1.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -60,6 +73,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -294,6 +358,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -342,6 +416,169 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -533,6 +770,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1825,6 +2080,82 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1836,6 +2167,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1881,6 +2220,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1944,6 +2294,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2284,6 +2641,150 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2324,6 +2825,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2347,6 +2859,45 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2427,6 +2978,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2516,6 +3077,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2628,6 +3199,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2756,6 +3348,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2784,6 +3390,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2806,6 +3419,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2816,6 +3439,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2851,6 +3482,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2869,6 +3513,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3103,6 +3754,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3119,6 +3780,16 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3453,6 +4124,35 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3500,6 +4200,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3532,6 +4242,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3539,6 +4256,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -3578,6 +4334,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3964,6 +4771,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3974,6 +4792,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3983,6 +4842,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4004,6 +4870,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4066,6 +4942,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4129,6 +5019,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4149,6 +5052,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4215,6 +5125,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4413,6 +5361,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4428,6 +5390,16 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -4485,6 +5457,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4530,6 +5515,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -4550,6 +5542,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4576,6 +5595,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -4613,6 +5639,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4630,6 +5673,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4700,6 +5799,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -5118,6 +6227,144 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5134,6 +6381,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5144,6 +6408,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index cbf9ba8..fbdc880 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -18,6 +21,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "highlight.js": "^11.11.1", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -33,19 +37,24 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@tailwindcss/postcss": "^4.1.17", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", + "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.22", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.12", "tailwindcss": "^4.1.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.10" } } diff --git a/frontend/src/components/buckets/ObjectDetailsView.tsx b/frontend/src/components/buckets/ObjectDetailsView.tsx index 4d497be..7190972 100644 --- a/frontend/src/components/buckets/ObjectDetailsView.tsx +++ b/frontend/src/components/buckets/ObjectDetailsView.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { IconTile } from '@/components/ui/icon-tile'; 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 { toast } from 'sonner'; import { downloadObject, formatBytes } from '@/lib/file-utils'; @@ -42,6 +43,7 @@ export function ObjectDetailsView() { const bucket = buckets.find((b) => b.name === bucketName); const canBucket = useBucketCan(); const canDelete = canBucket(bucket, 'object.delete'); + const canRead = canBucket(bucket, 'object.read'); const [metadata, setMetadata] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -216,9 +218,19 @@ export function ObjectDetailsView() { {/* Preview */} -
- No preview available for this object. -
+ {canRead && bucketName && objectKey ? ( + + ) : ( +
+ No preview available for this object. +
+ )}
({ useObjectPreview: vi.fn() })); + +const mockedHook = vi.mocked(useObjectPreview); + +function state(overrides: Partial): 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( + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/components/buckets/ObjectPreview.tsx b/frontend/src/components/buckets/ObjectPreview.tsx new file mode 100644 index 0000000..09e1297 --- /dev/null +++ b/frontend/src/components/buckets/ObjectPreview.tsx @@ -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 ( +
+

{message}

+ {(onRetry || onDownload) && ( +
+ {onRetry && ( + + )} + {onDownload && ( + + )} +
+ )} +
+ ); +} + +function CodeBlock({ text, objectKey }: { text: string; objectKey: string }) { + const [html, setHtml] = useState(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 ( +
+      {html !== null ?  : {text}}
+    
+ ); +} + +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(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 ; + case 'too-large': + return ( + + ); + case 'binary': + return ; + case 'error': + return ; + case 'loading': + return ( +
+ Loading preview… +
+ ); + } + + switch (preview.kind) { + case 'image': + return ( +
+ {objectKey} +
+ ); + case 'video': + return ( +
+
+ ); + case 'audio': + return ( +
+
+ ); + case 'pdf': + return