mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
feat(preview): Implement object preview functionality (#94)
* feat(preview): implement object preview functionality with token-based access * docs: update README to include inline object preview feature * test: add range read error handling and attachment content disposition tests
This commit is contained in:
@@ -66,5 +66,7 @@ config.yaml
|
||||
docs/**/*.md
|
||||
!docs/garage-setup.md
|
||||
!docs/access-control.md
|
||||
**/state**
|
||||
**/content**
|
||||
|
||||
**/worktrees
|
||||
@@ -32,6 +32,7 @@ A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garag
|
||||
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// Command seed bulk-loads a Garage/S3 bucket with millions of small objects
|
||||
// for testing the object browser and search features.
|
||||
//
|
||||
// Keys are a deterministic function of a global index, so a run is resumable:
|
||||
// re-run with -start=<last index> and any re-uploaded key simply overwrites.
|
||||
// Layout: pets/<species>/<breed>/<name>-<NNNNNN>.dat
|
||||
//
|
||||
// cd backend && go run ./cmd/seed # full 3,000,000 objects
|
||||
// go run ./cmd/seed -count 10000 # quick smoke test
|
||||
// go run ./cmd/seed -start 1500000 # resume from index 1.5M
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// species -> breeds. Real words so keys are searchable by substring
|
||||
// (e.g. "golden", "retriever", "siamese"). Flattened into speciesBreed at init.
|
||||
var taxonomy = []struct {
|
||||
species string
|
||||
breeds []string
|
||||
}{
|
||||
{"dogs", []string{"labrador", "golden-retriever", "german-shepherd", "bulldog", "poodle", "beagle", "rottweiler", "dachshund", "husky", "chihuahua"}},
|
||||
{"cats", []string{"siamese", "persian", "maine-coon", "bengal", "ragdoll", "sphynx", "british-shorthair", "abyssinian"}},
|
||||
{"birds", []string{"parrot", "canary", "cockatiel", "budgie", "finch", "macaw", "lovebird"}},
|
||||
{"fish", []string{"goldfish", "guppy", "betta", "angelfish", "tetra", "molly"}},
|
||||
{"rabbits", []string{"holland-lop", "netherland-dwarf", "rex", "lionhead", "flemish-giant"}},
|
||||
{"hamsters", []string{"syrian", "dwarf-campbell", "roborovski", "chinese"}},
|
||||
{"reptiles", []string{"leopard-gecko", "iguana", "bearded-dragon", "corn-snake", "box-turtle"}},
|
||||
{"horses", []string{"arabian", "thoroughbred", "mustang", "clydesdale", "appaloosa"}},
|
||||
{"guinea-pigs", []string{"american", "abyssinian", "peruvian", "silkie"}},
|
||||
{"ferrets", []string{"sable", "albino", "cinnamon", "chocolate"}},
|
||||
}
|
||||
|
||||
// petNames are the leaf file names. ~50 common pet names.
|
||||
var petNames = []string{
|
||||
"buddy", "luna", "max", "bella", "charlie", "lucy", "cooper", "daisy", "rocky", "molly",
|
||||
"bailey", "sadie", "duke", "maggie", "bear", "sophie", "tucker", "chloe", "oliver", "lola",
|
||||
"jack", "zoe", "toby", "ruby", "teddy", "rosie", "milo", "gracie", "oscar", "coco",
|
||||
"leo", "penny", "rex", "willow", "sam", "honey", "gus", "ginger", "murphy", "olive",
|
||||
"jasper", "hazel", "finn", "ivy", "louie", "pepper", "ziggy", "nala", "apollo", "cleo",
|
||||
}
|
||||
|
||||
type speciesBreedPair struct{ species, breed string }
|
||||
|
||||
var speciesBreed []speciesBreedPair
|
||||
|
||||
func init() {
|
||||
for _, t := range taxonomy {
|
||||
for _, b := range t.breeds {
|
||||
speciesBreed = append(speciesBreed, speciesBreedPair{t.species, b})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keyFor maps a global index to a unique object key with even folder fill.
|
||||
// folderIdx selects the species/breed folder; within selects (name, suffix)
|
||||
// inside that folder. The mapping is a bijection, so keys never collide.
|
||||
func keyFor(i int64) string {
|
||||
c := int64(len(speciesBreed))
|
||||
n := int64(len(petNames))
|
||||
folderIdx := i % c
|
||||
within := i / c
|
||||
name := petNames[within%n]
|
||||
suffix := within / n
|
||||
sb := speciesBreed[folderIdx]
|
||||
return fmt.Sprintf("pets/%s/%s/%s-%06d.dat", sb.species, sb.breed, name, suffix)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
endpoint = flag.String("endpoint", "localhost:3900", "S3 endpoint host:port")
|
||||
bucket = flag.String("bucket", "test", "target bucket")
|
||||
region = flag.String("region", "garage", "S3 region")
|
||||
accessKey = flag.String("access-key", "GK4b706791e6efb7bc00a99c69", "S3 access key")
|
||||
secretKey = flag.String("secret-key", "cdb665539872887e4fca34841ad2ebd79cda7af2302b500097262ec030123b14", "S3 secret key")
|
||||
count = flag.Int64("count", 3_000_000, "total dataset size (upper index, exclusive)")
|
||||
start = flag.Int64("start", 0, "start index (resume point)")
|
||||
size = flag.Int("size", 4096, "bytes per object")
|
||||
concurrency = flag.Int("concurrency", 64, "concurrent upload workers")
|
||||
secure = flag.Bool("secure", false, "use HTTPS")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
client, err := minio.New(*endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(*accessKey, *secretKey, ""),
|
||||
Secure: *secure,
|
||||
Region: *region,
|
||||
BucketLookup: minio.BucketLookupPath, // Garage needs path-style
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("client init: %v", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
total := *count - *start
|
||||
if total <= 0 {
|
||||
log.Fatalf("nothing to do: start=%d >= count=%d", *start, *count)
|
||||
}
|
||||
log.Printf("seeding bucket %q: indices [%d,%d) = %d objects of %d bytes, concurrency=%d, folders=%d",
|
||||
*bucket, *start, *count, total, *size, *concurrency, len(speciesBreed))
|
||||
|
||||
var (
|
||||
cursor = *start
|
||||
done int64
|
||||
errCount int64
|
||||
wg sync.WaitGroup
|
||||
startTime = time.Now()
|
||||
)
|
||||
|
||||
// Progress reporter.
|
||||
reportDone := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
var last int64
|
||||
lastT := startTime
|
||||
for {
|
||||
select {
|
||||
case <-reportDone:
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
d := atomic.LoadInt64(&done)
|
||||
cur := atomic.LoadInt64(&cursor)
|
||||
instRate := float64(d-last) / now.Sub(lastT).Seconds()
|
||||
last, lastT = d, now
|
||||
var eta time.Duration
|
||||
if instRate > 0 {
|
||||
eta = time.Duration(float64(total-d)/instRate) * time.Second
|
||||
}
|
||||
log.Printf("progress: %d/%d (%.1f%%) | %.0f obj/s | errors=%d | next-index=%d | eta=%s",
|
||||
d, total, 100*float64(d)/float64(total), instRate,
|
||||
atomic.LoadInt64(&errCount), cur, eta.Round(time.Second))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
opts := minio.PutObjectOptions{ContentType: "application/octet-stream", DisableMultipart: true}
|
||||
|
||||
for w := 0; w < *concurrency; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
// One random, incompressible buffer per worker (Garage compresses
|
||||
// per-object, so reuse is fine and avoids per-object allocation).
|
||||
buf := make([]byte, *size)
|
||||
r := rand.New(rand.NewSource(int64(1000 + worker)))
|
||||
for j := range buf {
|
||||
buf[j] = byte(r.Intn(256))
|
||||
}
|
||||
|
||||
for {
|
||||
idx := atomic.AddInt64(&cursor, 1) - 1
|
||||
if idx >= *count {
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
key := keyFor(idx)
|
||||
var putErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
_, putErr = client.PutObject(ctx, *bucket, key, bytes.NewReader(buf), int64(*size), opts)
|
||||
if putErr == nil || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
||||
}
|
||||
if putErr != nil {
|
||||
if n := atomic.AddInt64(&errCount, 1); n <= 10 {
|
||||
log.Printf("put %q failed: %v", key, putErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&done, 1)
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(reportDone)
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
d := atomic.LoadInt64(&done)
|
||||
log.Printf("DONE: uploaded %d/%d objects in %s (%.0f obj/s), errors=%d",
|
||||
d, total, elapsed.Round(time.Second), float64(d)/elapsed.Seconds(), atomic.LoadInt64(&errCount))
|
||||
if ctx.Err() != nil {
|
||||
log.Printf("interrupted; resume with -start=%d", atomic.LoadInt64(&cursor))
|
||||
}
|
||||
if atomic.LoadInt64(&errCount) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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/<bucket>/objects/<key>", the only shape the object GET
|
||||
// route matches. It parses c.Path() directly rather than reading Fiber's
|
||||
// bound :bucket/* route params, because those params are unset whenever this
|
||||
// runs ahead of the specific route match (see the AuthMiddleware comment
|
||||
// above). c.Path() reflects the incoming request path from the start of
|
||||
// request handling, independent of routing state, so this returns the same
|
||||
// answer no matter where in the chain it runs. Any path not matching the
|
||||
// shape returns ("", "").
|
||||
func previewRouteParts(c fiber.Ctx) (bucket, rawKey string) {
|
||||
const prefix = "/api/v1/buckets/"
|
||||
path := c.Path()
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", ""
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
slash := strings.IndexByte(rest, '/')
|
||||
if slash < 0 {
|
||||
return "", ""
|
||||
}
|
||||
bucket = rest[:slash]
|
||||
rest = rest[slash+1:]
|
||||
const objectsPrefix = "objects/"
|
||||
if !strings.HasPrefix(rest, objectsPrefix) {
|
||||
return "", ""
|
||||
}
|
||||
return bucket, rest[len(objectsPrefix):]
|
||||
}
|
||||
|
||||
// previewObjectKey decodes the wildcard object key the same way the routes
|
||||
// layer does. Requests targeting the JSON subroutes return "" because a
|
||||
// preview token only ever grants the plain byte download.
|
||||
func previewObjectKey(c fiber.Ctx) string {
|
||||
_, raw := previewRouteParts(c)
|
||||
// A raw trailing slash is the one case where c.Path() (used here) and the
|
||||
// served c.Params("*") diverge: Fiber trims the trailing slash from the
|
||||
// bound wildcard, so validating against the un-trimmed key could authorize
|
||||
// a token for "dir/" to serve "dir", or let "x/metadata/" reach the
|
||||
// /metadata subroute. The SPA always percent-encodes keys as one segment,
|
||||
// so a legitimate key ending in "/" arrives as "...%2F", never a raw
|
||||
// trailing slash. Refuse the raw-trailing-slash form so the validated key
|
||||
// can never diverge from the served key.
|
||||
if raw == "" || strings.HasSuffix(raw, "/") {
|
||||
return ""
|
||||
}
|
||||
decoded, err := url.QueryUnescape(raw)
|
||||
if err != nil {
|
||||
decoded = raw
|
||||
}
|
||||
for _, suffix := range []string{"/metadata", "/presign", "/preview-url"} {
|
||||
if strings.HasSuffix(decoded, suffix) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -669,3 +669,69 @@ func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectRange_SendsRangeHeaderAndStreamsBody(t *testing.T) {
|
||||
bucket := uniqueBucket2(t)
|
||||
var gotRange string
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotRange = r.Header.Get("Range")
|
||||
w.Header().Set("Content-Range", "bytes 2-6/10")
|
||||
w.Header().Set("Content-Length", "5")
|
||||
// The MinIO client parses Last-Modified from the response headers on
|
||||
// the first Read, so the fake server must set a valid one.
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("23456"))
|
||||
})
|
||||
s3 := newS3TestService(t, handler)
|
||||
|
||||
body, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 2, 6)
|
||||
if err != nil {
|
||||
t.Fatalf("GetObjectRange: %v", err)
|
||||
}
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(data) != "23456" {
|
||||
t.Errorf("body = %q, want %q", data, "23456")
|
||||
}
|
||||
if gotRange != "bytes=2-6" {
|
||||
t.Errorf("Range header = %q, want %q", gotRange, "bytes=2-6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectRange_InvalidRangeRejectedLocally(t *testing.T) {
|
||||
bucket := uniqueBucket2(t)
|
||||
var called bool
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
})
|
||||
s3 := newS3TestService(t, handler)
|
||||
|
||||
// end before start is a caller bug; SetRange rejects it before any request.
|
||||
if _, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 6, 2); err == nil {
|
||||
t.Fatal("expected error for inverted range")
|
||||
}
|
||||
if called {
|
||||
t.Error("no S3 request should be sent for an invalid range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectRange_ClientAcquisitionFailurePropagates(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := s3.GetObjectRange(ctx, bucket, "missing", 0, 4); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ coverage:
|
||||
|
||||
ignore:
|
||||
- "backend/main.go"
|
||||
- "backend/cmd/"
|
||||
- "backend/docs/"
|
||||
- "backend/internal/services/mocks/"
|
||||
- "backend/**/*_mock.go"
|
||||
|
||||
Generated
+1282
-1
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -216,9 +218,19 @@ export function ObjectDetailsView() {
|
||||
|
||||
{/* Preview */}
|
||||
<CardSection title="Preview">
|
||||
{canRead && bucketName && objectKey ? (
|
||||
<ObjectPreview
|
||||
bucket={bucketName}
|
||||
objectKey={objectKey}
|
||||
size={metadata.size}
|
||||
contentType={metadata.contentType}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
No preview available for this object.
|
||||
</div>
|
||||
)}
|
||||
</CardSection>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ObjectPreviewState } from '@/hooks/useObjectPreview';
|
||||
import { useObjectPreview } from '@/hooks/useObjectPreview';
|
||||
import { TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
|
||||
import { ObjectPreview } from './ObjectPreview';
|
||||
|
||||
vi.mock('@/hooks/useObjectPreview', () => ({ useObjectPreview: vi.fn() }));
|
||||
|
||||
const mockedHook = vi.mocked(useObjectPreview);
|
||||
|
||||
function state(overrides: Partial<ObjectPreviewState>): ObjectPreviewState {
|
||||
return {
|
||||
kind: 'none',
|
||||
status: 'unsupported',
|
||||
objectUrl: null,
|
||||
text: null,
|
||||
mediaUrl: null,
|
||||
retry: vi.fn(),
|
||||
onMediaError: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
// A .json key keeps the highlight language deterministic in the text test.
|
||||
return render(
|
||||
<ObjectPreview bucket="b" objectKey="k.json" size={100} contentType="text/plain" onDownload={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ObjectPreview', () => {
|
||||
it('shows the loading state', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'loading' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/loading preview/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an image from the object url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'ready', objectUrl: 'blob:img' }));
|
||||
renderPreview();
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:img');
|
||||
});
|
||||
|
||||
it('renders video with the media url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const video = container.querySelector('video');
|
||||
expect(video).not.toBeNull();
|
||||
expect(video).toHaveAttribute('src', '/u?pt=t');
|
||||
expect(video).not.toHaveAttribute('autoplay');
|
||||
});
|
||||
|
||||
it('renders audio with the media url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('audio')).toHaveAttribute('src', '/u?pt=t');
|
||||
});
|
||||
|
||||
it('renders pdf in an iframe', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'pdf', status: 'ready', objectUrl: 'blob:pdf' }));
|
||||
renderPreview();
|
||||
expect(screen.getByTitle('k.json')).toHaveAttribute('src', 'blob:pdf');
|
||||
});
|
||||
|
||||
it('renders plain text immediately and highlighted text after the import resolves', async () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('pre')).toHaveTextContent('{"a": 1}');
|
||||
await waitFor(() => expect(container.querySelector('code [class*="hljs-"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('shows the too-large notice with a download action', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'too-large' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/too large to preview/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unsupported notice', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'none', status: 'unsupported' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the binary notice', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'binary' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/doesn't appear to be text/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error state with retry wired to the hook', () => {
|
||||
const retry = vi.fn();
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'error', retry }));
|
||||
renderPreview();
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
expect(retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The tests below are additional to the brief's list. They were added to
|
||||
// close coverage gaps (media error and resume handling, the highlight size
|
||||
// guard, and the defensive fallback branch) found while verifying the
|
||||
// 90 percent coverage requirement on this file.
|
||||
|
||||
it('skips highlighting for text over the highlight size limit', () => {
|
||||
const bigText = 'a'.repeat(TEXT_HIGHLIGHT_MAX_BYTES + 1);
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: bigText }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('code [class*="hljs-"]')).toBeNull();
|
||||
expect(container.querySelector('code')?.textContent).toHaveLength(bigText.length);
|
||||
});
|
||||
|
||||
it('unmounting before the highlight import resolves is safe', async () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
|
||||
const { container, unmount } = renderPreview();
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(container.innerHTML).toBe('');
|
||||
// Let the pending dynamic import resolve after unmount. The cancelled
|
||||
// guard means the resolved callback renders nothing back into the
|
||||
// detached container, so it stays empty and no error is thrown.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('resumes the playback position after a media error and reload', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const video = container.querySelector('video')!;
|
||||
|
||||
// Loading metadata with no prior error is a no-op (resume position is 0).
|
||||
fireEvent.loadedMetadata(video);
|
||||
|
||||
Object.defineProperty(video, 'currentTime', { value: 42, writable: true, configurable: true });
|
||||
fireEvent.error(video);
|
||||
Object.defineProperty(video, 'currentTime', { value: 0, writable: true, configurable: true });
|
||||
fireEvent.loadedMetadata(video);
|
||||
expect(video.currentTime).toBe(42);
|
||||
});
|
||||
|
||||
it('does not restore a position when the media element reported no currentTime', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const audio = container.querySelector('audio')!;
|
||||
|
||||
// The element reports no currentTime, so the captured resume position
|
||||
// falls back to 0 via the ?? 0 guard, which keeps the restore guard
|
||||
// (resumeAtRef.current > 0) false. A later loadedmetadata must then leave
|
||||
// the position untouched, unlike the sibling resume test above.
|
||||
Object.defineProperty(audio, 'currentTime', { value: undefined, writable: true, configurable: true });
|
||||
fireEvent.error(audio);
|
||||
Object.defineProperty(audio, 'currentTime', { value: 99, writable: true, configurable: true });
|
||||
fireEvent.loadedMetadata(audio);
|
||||
expect(audio.currentTime).toBe(99);
|
||||
});
|
||||
|
||||
it('falls back to the generic notice for an unhandled preview kind', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'none', status: 'ready' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useObjectPreview } from '@/hooks/useObjectPreview';
|
||||
import { getHighlightLanguage, TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
|
||||
function Notice({
|
||||
message,
|
||||
onDownload,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onDownload?: () => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
<p>{message}</p>
|
||||
{(onRetry || onDownload) && (
|
||||
<div className="flex gap-2">
|
||||
{onRetry && (
|
||||
<Button variant="secondary" onClick={onRetry}>
|
||||
<RefreshCw className="h-4 w-4" /> Retry
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button variant="secondary" onClick={onDownload}>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ text, objectKey }: { text: string; objectKey: string }) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Highlighting is progressive enhancement. Large files and any import or
|
||||
// highlight failure fall back to the plain text already on screen.
|
||||
if (text.length > TEXT_HIGHLIGHT_MAX_BYTES) return;
|
||||
let cancelled = false;
|
||||
import('@/lib/highlight')
|
||||
.then(({ highlight }) => {
|
||||
if (!cancelled) setHtml(highlight(text, getHighlightLanguage(objectKey)));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [text, objectKey]);
|
||||
|
||||
return (
|
||||
<pre className="overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-relaxed">
|
||||
{html !== null ? <code dangerouslySetInnerHTML={{ __html: html }} /> : <code>{text}</code>}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectPreview({
|
||||
bucket,
|
||||
objectKey,
|
||||
size,
|
||||
contentType,
|
||||
onDownload,
|
||||
}: {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const preview = useObjectPreview(bucket, objectKey, size, contentType);
|
||||
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const resumeAtRef = useRef(0);
|
||||
|
||||
const handleMediaError = () => {
|
||||
resumeAtRef.current = mediaRef.current?.currentTime ?? 0;
|
||||
preview.onMediaError();
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (resumeAtRef.current > 0 && mediaRef.current) {
|
||||
mediaRef.current.currentTime = resumeAtRef.current;
|
||||
resumeAtRef.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
switch (preview.status) {
|
||||
case 'unsupported':
|
||||
return <Notice message="No preview available for this object." onDownload={onDownload} />;
|
||||
case 'too-large':
|
||||
return (
|
||||
<Notice
|
||||
message={`File is too large to preview (${formatBytes(size)}), download it instead.`}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
case 'binary':
|
||||
return <Notice message="This file doesn't appear to be text." onDownload={onDownload} />;
|
||||
case 'error':
|
||||
return <Notice message="Could not load the preview." onRetry={preview.retry} onDownload={onDownload} />;
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-10 text-[13px] text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading preview…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (preview.kind) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex justify-center bg-[var(--surface-sunken)] px-5 py-6">
|
||||
<img src={preview.objectUrl!} alt={objectKey} className="h-auto max-w-full object-contain" />
|
||||
</div>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex justify-center bg-black">
|
||||
<video
|
||||
ref={(el) => {
|
||||
mediaRef.current = el;
|
||||
}}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={preview.mediaUrl!}
|
||||
onError={handleMediaError}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
className="max-h-[85vh] w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="px-5 py-6">
|
||||
<audio
|
||||
ref={(el) => {
|
||||
mediaRef.current = el;
|
||||
}}
|
||||
controls
|
||||
src={preview.mediaUrl!}
|
||||
onError={handleMediaError}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'pdf':
|
||||
return <iframe src={preview.objectUrl!} title={objectKey} className="h-[85vh] w-full" />;
|
||||
case 'text':
|
||||
return <CodeBlock text={preview.text!} objectKey={objectKey} />;
|
||||
default:
|
||||
return <Notice message="No preview available for this object." onDownload={onDownload} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import { useObjectPreview } from './useObjectPreview';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
objectsApi: {
|
||||
get: vi.fn(),
|
||||
getPreviewUrl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedGet = vi.mocked(objectsApi.get);
|
||||
const mockedGetPreviewUrl = vi.mocked(objectsApi.getPreviewUrl);
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// One client per test, created outside the component so rerenders reuse it.
|
||||
function createWrapper() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('useObjectPreview', () => {
|
||||
it('fetches a blob and produces an object url for images', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])]));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.kind).toBe('image');
|
||||
expect(result.current.objectUrl).toBe('blob:mock-url');
|
||||
expect(mockedGet).toHaveBeenCalledWith('b', 'pic.png');
|
||||
});
|
||||
|
||||
it('decodes text content', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob(['{"a": 1}']));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'data.json', 8, 'application/json'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.text).toBe('{"a": 1}');
|
||||
});
|
||||
|
||||
it('reports binary content pretending to be text', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([0, 1, 2, 3, 4, 0, 1, 2, 3, 4])]));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'weird.log', 10, undefined), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('binary'));
|
||||
});
|
||||
|
||||
it('does not fetch when the object is over the limit', () => {
|
||||
const { result } = renderHook(
|
||||
() => useObjectPreview('b', 'big.txt', 6 * 1024 * 1024, 'text/plain'),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
expect(result.current.status).toBe('too-large');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unsupported kinds without fetching', () => {
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'blob.bin', 10, undefined), { wrapper: createWrapper() });
|
||||
expect(result.current.status).toBe('unsupported');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints a media url for video without fetching bytes', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/api/v1/buckets/b/objects/v.mp4?pt=tok', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10_000_000_000, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.mediaUrl).toBe('/api/v1/buckets/b/objects/v.mp4?pt=tok');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-mints once on media error, then reports error', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
|
||||
expect(result.current.status).toBe('ready');
|
||||
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
});
|
||||
|
||||
it('reports fetch failures and recovers on retry', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('network down'));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
});
|
||||
|
||||
it('revokes the object url on unmount', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
|
||||
const { result, unmount } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
unmount();
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||
});
|
||||
|
||||
it('recovers when the target object changes after a media error', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/a?pt=1', expiresAt: 'later' });
|
||||
const { result, rerender } = renderHook(
|
||||
({ k }) => useObjectPreview('b', k, 10, 'video/mp4'),
|
||||
{ wrapper: createWrapper(), initialProps: { k: 'a.mp4' } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
// Exhaust the single re-mint on object A, driving it into the error state.
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
// Switch to object B on the same hook instance. The stale error from A
|
||||
// must not leave B stuck: it should load and reach ready.
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/b?pt=1', expiresAt: 'later' });
|
||||
rerender({ k: 'b.mp4' });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.mediaUrl).toBe('/b?pt=1');
|
||||
});
|
||||
|
||||
it('refetches the media url on retry', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('ignores a pending text decode after unmount', async () => {
|
||||
let resolveText: (value: string) => void = () => {};
|
||||
const blob = new Blob(['hello']);
|
||||
vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
|
||||
mockedGet.mockResolvedValue(blob);
|
||||
const { result, unmount } = renderHook(() => useObjectPreview('b', 'a.txt', 5, 'text/plain'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.objectUrl).toBe('blob:mock-url'));
|
||||
|
||||
// Unmount before the decode resolves, then resolve it. The cancelled
|
||||
// guard must swallow the late result rather than set state.
|
||||
unmount();
|
||||
act(() => resolveText('hello'));
|
||||
expect(result.current.text).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import {
|
||||
getPreviewKind,
|
||||
getPreviewMime,
|
||||
getPreviewSizeLimit,
|
||||
looksBinary,
|
||||
type PreviewKind,
|
||||
} from '@/lib/preview-utils';
|
||||
|
||||
export interface ObjectPreviewState {
|
||||
kind: PreviewKind;
|
||||
status: 'loading' | 'ready' | 'too-large' | 'unsupported' | 'binary' | 'error';
|
||||
objectUrl: string | null;
|
||||
text: string | null;
|
||||
mediaUrl: string | null;
|
||||
retry: () => void;
|
||||
onMediaError: () => void;
|
||||
}
|
||||
|
||||
// Documents (image, pdf, text) are fetched as blobs through the normal
|
||||
// authenticated API path. Media (video, audio) gets a short-lived tokenized
|
||||
// URL instead, because media elements cannot send an Authorization header
|
||||
// and must stream with Range requests rather than load fully.
|
||||
export function useObjectPreview(
|
||||
bucket: string,
|
||||
objectKey: string,
|
||||
size: number,
|
||||
contentType?: string,
|
||||
): ObjectPreviewState {
|
||||
const kind = getPreviewKind(contentType, objectKey);
|
||||
const sizeLimit = getPreviewSizeLimit(kind);
|
||||
const isDocument = kind === 'image' || kind === 'pdf' || kind === 'text';
|
||||
const isMedia = kind === 'video' || kind === 'audio';
|
||||
const tooLarge = isDocument && sizeLimit !== null && size > sizeLimit;
|
||||
|
||||
const blobQuery = useQuery({
|
||||
queryKey: ['object-preview', bucket, objectKey],
|
||||
queryFn: () => objectsApi.get(bucket, objectKey),
|
||||
enabled: isDocument && !tooLarge,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const urlQuery = useQuery({
|
||||
queryKey: ['object-preview-url', bucket, objectKey],
|
||||
queryFn: () => objectsApi.getPreviewUrl(bucket, objectKey),
|
||||
enabled: isMedia,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const [mediaFailed, setMediaFailed] = useState(false);
|
||||
const remintedRef = useRef(false);
|
||||
|
||||
// Reset media error state when the target object changes, so a failure on
|
||||
// one object does not leave a later object stuck in the error state. The
|
||||
// hook instance is reused across navigation, it does not remount.
|
||||
useEffect(() => {
|
||||
setMediaFailed(false);
|
||||
remintedRef.current = false;
|
||||
}, [bucket, objectKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const blob = blobQuery.data;
|
||||
if (!blob) return;
|
||||
let cancelled = false;
|
||||
const typed = new Blob([blob], { type: getPreviewMime(kind, contentType, objectKey) });
|
||||
const url = URL.createObjectURL(typed);
|
||||
setObjectUrl(url);
|
||||
if (kind === 'text') {
|
||||
blob.text().then((decoded) => {
|
||||
if (cancelled) return;
|
||||
if (looksBinary(decoded.slice(0, 4096))) setIsBinary(true);
|
||||
else setText(decoded);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
setObjectUrl(null);
|
||||
setText(null);
|
||||
setIsBinary(false);
|
||||
};
|
||||
}, [blobQuery.data, kind, contentType, objectKey]);
|
||||
|
||||
const onMediaError = () => {
|
||||
if (remintedRef.current) {
|
||||
setMediaFailed(true);
|
||||
return;
|
||||
}
|
||||
remintedRef.current = true;
|
||||
urlQuery.refetch();
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
remintedRef.current = false;
|
||||
setMediaFailed(false);
|
||||
if (isDocument) blobQuery.refetch();
|
||||
if (isMedia) urlQuery.refetch();
|
||||
};
|
||||
|
||||
let status: ObjectPreviewState['status'];
|
||||
if (kind === 'none') status = 'unsupported';
|
||||
else if (tooLarge) status = 'too-large';
|
||||
else if (isBinary) status = 'binary';
|
||||
else if (mediaFailed || blobQuery.isError || urlQuery.isError) status = 'error';
|
||||
else if (isMedia) status = urlQuery.data ? 'ready' : 'loading';
|
||||
else if (kind === 'text') status = text !== null ? 'ready' : 'loading';
|
||||
else status = objectUrl ? 'ready' : 'loading';
|
||||
|
||||
return {
|
||||
kind,
|
||||
status,
|
||||
objectUrl,
|
||||
text,
|
||||
mediaUrl: urlQuery.data?.url ?? null,
|
||||
retry,
|
||||
onMediaError,
|
||||
};
|
||||
}
|
||||
@@ -161,3 +161,77 @@
|
||||
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Syntax highlighting tokens for the object preview. The dark overrides ride
|
||||
the same root class the theme provider toggles. */
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6a737d;
|
||||
}
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #d73a49;
|
||||
}
|
||||
.hljs-number,
|
||||
.hljs-literal,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-string,
|
||||
.hljs-doctag,
|
||||
.hljs-regexp {
|
||||
color: #032f62;
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-name {
|
||||
color: #6f42c1;
|
||||
}
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-built_in {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.dark .hljs-comment,
|
||||
.dark .hljs-quote {
|
||||
color: #8b949e;
|
||||
}
|
||||
.dark .hljs-keyword,
|
||||
.dark .hljs-selector-tag,
|
||||
.dark .hljs-subst {
|
||||
color: #ff7b72;
|
||||
}
|
||||
.dark .hljs-number,
|
||||
.dark .hljs-literal,
|
||||
.dark .hljs-variable,
|
||||
.dark .hljs-template-variable {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .hljs-string,
|
||||
.dark .hljs-doctag,
|
||||
.dark .hljs-regexp {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
.dark .hljs-title,
|
||||
.dark .hljs-section,
|
||||
.dark .hljs-name {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
.dark .hljs-attr,
|
||||
.dark .hljs-attribute,
|
||||
.dark .hljs-built_in {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .hljs-meta,
|
||||
.dark .hljs-selector-id,
|
||||
.dark .hljs-selector-class {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
@@ -395,6 +395,12 @@ export const objectsApi = {
|
||||
});
|
||||
return response.data.data.url;
|
||||
},
|
||||
|
||||
getPreviewUrl: async (bucket: string, key: string): Promise<{ url: string; expiresAt: string }> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/preview-url`);
|
||||
const data = response.data.data;
|
||||
return { url: data.url, expiresAt: data.expires_at };
|
||||
},
|
||||
};
|
||||
|
||||
// Access Control API (Users/Keys)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlight } from './highlight';
|
||||
|
||||
describe('highlight', () => {
|
||||
it('highlights a known language', () => {
|
||||
const html = highlight('{"a": 1}', 'json');
|
||||
expect(html).toContain('hljs-');
|
||||
});
|
||||
|
||||
it('escapes html in the source', () => {
|
||||
const html = highlight('<script>alert(1)</script>', 'xml');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('falls back to auto detection for null language', () => {
|
||||
const html = highlight('SELECT * FROM t;', null);
|
||||
expect(typeof html).toBe('string');
|
||||
expect(html.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to auto detection for an unregistered language', () => {
|
||||
const html = highlight('plain words', 'klingon');
|
||||
expect(typeof html).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Loaded only via dynamic import() from the text preview renderer, so
|
||||
// highlight.js never enters the main bundle.
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import dockerfile from 'highlight.js/lib/languages/dockerfile';
|
||||
import go from 'highlight.js/lib/languages/go';
|
||||
import ini from 'highlight.js/lib/languages/ini';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import markdown from 'highlight.js/lib/languages/markdown';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('dockerfile', dockerfile);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('ini', ini);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('markdown', markdown);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
|
||||
// Returns highlighted HTML for the source text. hljs escapes the input, so
|
||||
// the output is safe to assign as innerHTML.
|
||||
export function highlight(text: string, language: string | null): string {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(text, { language }).value;
|
||||
}
|
||||
return hljs.highlightAuto(text).value;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getHighlightLanguage,
|
||||
getPreviewKind,
|
||||
getPreviewMime,
|
||||
getPreviewSizeLimit,
|
||||
looksBinary,
|
||||
IMAGE_PREVIEW_MAX_BYTES,
|
||||
PDF_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
} from './preview-utils';
|
||||
|
||||
describe('getPreviewKind', () => {
|
||||
it('trusts a specific content type first', () => {
|
||||
expect(getPreviewKind('image/png', 'noext')).toBe('image');
|
||||
expect(getPreviewKind('video/mp4', 'noext')).toBe('video');
|
||||
expect(getPreviewKind('audio/mpeg', 'noext')).toBe('audio');
|
||||
expect(getPreviewKind('application/pdf', 'noext')).toBe('pdf');
|
||||
expect(getPreviewKind('text/plain', 'noext')).toBe('text');
|
||||
expect(getPreviewKind('application/json', 'noext')).toBe('text');
|
||||
});
|
||||
|
||||
it('ignores content type parameters', () => {
|
||||
expect(getPreviewKind('text/plain; charset=utf-8', 'noext')).toBe('text');
|
||||
});
|
||||
|
||||
it('falls back to the extension when the type is octet-stream', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'photo.JPG')).toBe('image');
|
||||
expect(getPreviewKind('application/octet-stream', 'clip.mp4')).toBe('video');
|
||||
expect(getPreviewKind('application/octet-stream', 'song.flac')).toBe('audio');
|
||||
expect(getPreviewKind('application/octet-stream', 'doc.pdf')).toBe('pdf');
|
||||
expect(getPreviewKind('application/octet-stream', 'conf.yaml')).toBe('text');
|
||||
expect(getPreviewKind(undefined, 'a/b/c.json')).toBe('text');
|
||||
});
|
||||
|
||||
it('detects svg as image even though the backend rewrites its type', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'logo.svg')).toBe('image');
|
||||
});
|
||||
|
||||
it('detects Dockerfile without an extension', () => {
|
||||
expect(getPreviewKind(undefined, 'build/Dockerfile')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns none for unknown files', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'blob.bin')).toBe('none');
|
||||
expect(getPreviewKind(undefined, 'noext')).toBe('none');
|
||||
expect(getPreviewKind(undefined, 'archive.zip')).toBe('none');
|
||||
});
|
||||
|
||||
it('does not treat a dotfile name as an extension', () => {
|
||||
expect(getPreviewKind(undefined, '.gitignore')).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewSizeLimit', () => {
|
||||
it('caps documents per kind and leaves media unlimited', () => {
|
||||
expect(getPreviewSizeLimit('text')).toBe(TEXT_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('image')).toBe(IMAGE_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('pdf')).toBe(PDF_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('video')).toBeNull();
|
||||
expect(getPreviewSizeLimit('audio')).toBeNull();
|
||||
expect(getPreviewSizeLimit('none')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewMime', () => {
|
||||
it('restores the mime type the backend rewrote', () => {
|
||||
expect(getPreviewMime('image', 'application/octet-stream', 'logo.svg')).toBe('image/svg+xml');
|
||||
expect(getPreviewMime('pdf', 'application/octet-stream', 'doc.pdf')).toBe('application/pdf');
|
||||
});
|
||||
it('keeps a usable content type when there is no mapping', () => {
|
||||
expect(getPreviewMime('text', 'text/plain', 'readme.txt')).toBe('text/plain');
|
||||
expect(getPreviewMime('text', undefined, 'readme.weird')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHighlightLanguage', () => {
|
||||
it('maps common extensions', () => {
|
||||
expect(getHighlightLanguage('a.json')).toBe('json');
|
||||
expect(getHighlightLanguage('a.yml')).toBe('yaml');
|
||||
expect(getHighlightLanguage('a.tsx')).toBe('typescript');
|
||||
expect(getHighlightLanguage('Dockerfile')).toBe('dockerfile');
|
||||
});
|
||||
it('returns null for unmapped extensions', () => {
|
||||
expect(getHighlightLanguage('a.log')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksBinary', () => {
|
||||
it('accepts ordinary text with newlines and tabs', () => {
|
||||
expect(looksBinary('hello\n\tworld\r\n')).toBe(false);
|
||||
expect(looksBinary('')).toBe(false);
|
||||
});
|
||||
it('flags replacement-heavy content', () => {
|
||||
expect(looksBinary('���ab')).toBe(true);
|
||||
});
|
||||
it('flags control-character-heavy content', () => {
|
||||
expect(looksBinary('\x00\x01\x02abc')).toBe(true);
|
||||
});
|
||||
it('tolerates a small fraction of oddities', () => {
|
||||
expect(looksBinary('a'.repeat(99) + '\x00')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'none';
|
||||
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
||||
export const TEXT_HIGHLIGHT_MAX_BYTES = 1 * 1024 * 1024;
|
||||
export const IMAGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
|
||||
export const PDF_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const imageMimeByExtension: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
ico: 'image/x-icon',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const videoExtensions = new Set(['mp4', 'webm', 'ogv', 'mov']);
|
||||
const audioExtensions = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
|
||||
const textExtensions = new Set([
|
||||
'json', 'yaml', 'yml', 'ini', 'toml', 'xml', 'csv', 'md', 'log', 'txt',
|
||||
'conf', 'cfg', 'env', 'sh', 'bash', 'py', 'js', 'ts', 'jsx', 'tsx',
|
||||
'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'sql', 'html', 'css', 'dockerfile',
|
||||
]);
|
||||
|
||||
function getExtension(key: string): string {
|
||||
const name = key.split('/').pop() ?? '';
|
||||
if (name.toLowerCase() === 'dockerfile') return 'dockerfile';
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Content-Type first, extension fallback. The fallback matters: Garage often
|
||||
// stores application/octet-stream, and the backend rewrites unsafe types to
|
||||
// it, so a generic type defers to the file extension.
|
||||
export function getPreviewKind(contentType: string | undefined, key: string): PreviewKind {
|
||||
const ct = (contentType ?? '').split(';')[0].trim().toLowerCase();
|
||||
if (ct && ct !== 'application/octet-stream') {
|
||||
if (ct.startsWith('image/')) return 'image';
|
||||
if (ct.startsWith('video/')) return 'video';
|
||||
if (ct.startsWith('audio/')) return 'audio';
|
||||
if (ct === 'application/pdf') return 'pdf';
|
||||
if (ct.startsWith('text/') || ct === 'application/json') return 'text';
|
||||
}
|
||||
const ext = getExtension(key);
|
||||
if (ext in imageMimeByExtension) return 'image';
|
||||
if (videoExtensions.has(ext)) return 'video';
|
||||
if (audioExtensions.has(ext)) return 'audio';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (textExtensions.has(ext)) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// Byte limit per kind. null means no limit because media streams with Range
|
||||
// requests and is never fully loaded into memory.
|
||||
export function getPreviewSizeLimit(kind: PreviewKind): number | null {
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return TEXT_PREVIEW_MAX_BYTES;
|
||||
case 'image':
|
||||
return IMAGE_PREVIEW_MAX_BYTES;
|
||||
case 'pdf':
|
||||
return PDF_PREVIEW_MAX_BYTES;
|
||||
case 'video':
|
||||
case 'audio':
|
||||
return null;
|
||||
case 'none':
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The backend rewrites unsafe Content-Types to application/octet-stream, so
|
||||
// blob URLs need their MIME type restored for the SVG <img> case and the
|
||||
// browser PDF viewer.
|
||||
export function getPreviewMime(kind: PreviewKind, contentType: string | undefined, key: string): string {
|
||||
if (kind === 'image') {
|
||||
return imageMimeByExtension[getExtension(key)] ?? contentType ?? 'application/octet-stream';
|
||||
}
|
||||
if (kind === 'pdf') return 'application/pdf';
|
||||
return contentType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const languageByExtension: Record<string, string> = {
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
ini: 'ini',
|
||||
toml: 'ini',
|
||||
xml: 'xml',
|
||||
md: 'markdown',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
py: 'python',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
go: 'go',
|
||||
sql: 'sql',
|
||||
html: 'xml',
|
||||
css: 'css',
|
||||
dockerfile: 'dockerfile',
|
||||
};
|
||||
|
||||
export function getHighlightLanguage(key: string): string | null {
|
||||
return languageByExtension[getExtension(key)] ?? null;
|
||||
}
|
||||
|
||||
// A text preview only makes sense for content that decodes as text. More
|
||||
// than 10 percent replacement or non-whitespace control characters in the
|
||||
// sample means the file is binary despite its name.
|
||||
export function looksBinary(sample: string): boolean {
|
||||
if (sample.length === 0) return false;
|
||||
let suspicious = 0;
|
||||
let total = 0;
|
||||
for (const ch of sample) {
|
||||
total++;
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
|
||||
suspicious++;
|
||||
}
|
||||
}
|
||||
return suspicious / total > 0.1;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Testing Library's automatic cleanup only self-registers when a global
|
||||
// afterEach exists. This harness does not set test.globals, so register it
|
||||
// explicitly to unmount rendered trees between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: [
|
||||
'src/lib/preview-utils.ts',
|
||||
'src/lib/highlight.ts',
|
||||
'src/hooks/useObjectPreview.ts',
|
||||
'src/components/buckets/ObjectPreview.tsx',
|
||||
],
|
||||
thresholds: { statements: 90, branches: 90, functions: 90, lines: 90 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user