Compare commits

...

10 Commits

Author SHA1 Message Date
Noooste 6766dec933 feat(bucket): add functionality to create buckets with optional access keys and permissions 2026-07-25 19:38:01 +02:00
Noooste a061500d50 feat(frontend): make bucket items clickable links to object view 2026-07-25 12:32:49 +02:00
Noooste 985b7b30ce feat(dropdown): improve positioning logic and add popup positioning utility 2026-07-25 12:29:31 +02:00
dependabot[bot] 8f6cb2b1da chore(deps): bump axios from 1.16.0 to 1.18.0 in /frontend (#96)
Bumps [axios](https://github.com/axios/axios) from 1.16.0 to 1.18.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.0...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 09:52:05 +02:00
garage-ui-release-bot[bot] 0e6b7b9215 chore(main): release 0.10.0 (#93)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-15 00:02:03 +02:00
Noste c0976a1e1d 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
2026-07-15 00:01:04 +02:00
Camilo Hollanda 5d33382e30 feat: bulk actions — recursively delete folders (key prefixes) (#68)
* feat(backend,frontend): recursive delete of key prefixes in bulk actions

Bulk object selection previously only supported deleting individually
listed object keys — folders ("prefixes") could not be selected or
deleted, leaving no way to remove a directory and all of its contents.

Backend:
- Add S3Service.DeleteObjectsByPrefix, which recursively lists every
  object under a prefix and batch-deletes them, returning the count.
- Extend the delete-multiple endpoint to accept a "prefixes" array
  alongside "keys"; keys are batch-deleted and each prefix is deleted
  recursively. Response now reports the total objects removed.

Frontend:
- Enable folder checkboxes and add a per-folder "Delete folder" action.
- Select-all now covers both files and folders.
- Route all bulk/folder deletes through a confirmation dialog that
  spells out the file/folder counts and warns that folders are removed
  recursively (previously bulk delete fired with no confirmation).
- api/hook send "prefixes"; optimistic update drops objects under any
  deleted prefix.

* fix(backend): validate delete prefixes and count actual removals

Addresses maintainer review on the recursive prefix-delete endpoint:

- Reject blank/whitespace-only prefixes with a 400 instead of a 500, and
  normalize each prefix to have a trailing "/" so "photos/2024" can no
  longer also delete siblings like "photos/2024-old/..." on this
  irreversible public endpoint.
- DeleteMultipleObjects now returns the number of objects actually
  removed (requested keys minus failures) rather than assuming every
  requested key was deleted; the handler sums real counts across the
  keys and prefix paths. Draining the full RemoveObjects error channel
  also fixes a potential sender-goroutine leak on early return.
- Add tests: blank-prefix -> 400, prefix trailing-slash normalization,
  and S3Service.DeleteObjectsByPrefix (list-then-delete, empty prefix,
  no-match, and list-error propagation).

* fix(frontend): align select-all with the active search filter

The header "select all" checkbox derived its checked state from the
filtered (searched) rows, but handleSelectAll operated on the full,
unfiltered object list — so with a search active it selected hidden
items and the checkbox state disagreed with the selection.

ObjectsTable now passes the keys of the currently visible (filtered)
rows to onSelectAll, and its checked state reflects whether every
visible row is selected. handleSelectAll toggles only those visible
rows, leaving any off-screen selection intact.

* fix(frontend): scope select-all to the visible page

After merging upstream's client-side deep-search pagination, the rendered
rows are pageObjects (one page slice) while select-all still operated on
filteredObjects (every match across hidden pages). Scope the header
checkbox's state and its select-all action to pageObjects so one click
never selects off-screen rows for a destructive bulk delete. In
normal/prefix browsing pageObjects === filteredObjects, so behavior there
is unchanged.

---------

Authored-by: Camilo Hollanda <775409+prem-prakash@users.noreply.github.com>
2026-07-14 23:11:23 +02:00
Noooste 477b544be7 docs: improve clarity and structure 2026-07-11 22:53:40 +02:00
Noooste 90dffe594a update README.md 2026-07-11 22:22:49 +02:00
Noste 9f73d032e6 feat(metrics): add public metrics endpoint configuration and update documentation (#92) 2026-07-11 21:00:04 +02:00
64 changed files with 5283 additions and 380 deletions
+2
View File
@@ -66,5 +66,7 @@ config.yaml
docs/**/*.md
!docs/garage-setup.md
!docs/access-control.md
**/state**
**/content**
**/worktrees
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.9.0"
".": "0.10.0"
}
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## [0.10.0](https://github.com/Noooste/garage-ui/compare/v0.9.0...v0.10.0) (2026-07-14)
### Features
* bulk actions — recursively delete folders (key prefixes) ([#68](https://github.com/Noooste/garage-ui/issues/68)) ([5d33382](https://github.com/Noooste/garage-ui/commit/5d33382e3056afd5b75b1775255e0f3033833f6c))
* **metrics:** add public metrics endpoint configuration and update documentation ([#92](https://github.com/Noooste/garage-ui/issues/92)) ([9f73d03](https://github.com/Noooste/garage-ui/commit/9f73d032e6c1c10b7d0dafa986e32985aa3b6f66))
* **preview:** Implement object preview functionality ([#94](https://github.com/Noooste/garage-ui/issues/94)) ([c0976a1](https://github.com/Noooste/garage-ui/commit/c0976a1e1d53839d1fe9d7158e8cadb979789722))
## [0.9.0](https://github.com/Noooste/garage-ui/compare/v0.8.5...v0.9.0) (2026-07-11)
+30 -49
View File
@@ -32,6 +32,7 @@ A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garag
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
- **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
@@ -78,11 +79,17 @@ helm install garage-ui garage-ui/garage-ui \
--set garage.adminToken=your-token
```
Access at http://localhost:8080
The chart creates a ClusterIP service on port 80. To try it out before setting up an ingress:
### Quick Start with garage.toml
```bash
kubectl port-forward svc/garage-ui 8080:80
```
If you already have a running Garage instance, you can point Garage UI directly at your `garage.toml` -- no `config.yaml` needed:
Then open http://localhost:8080
### Reusing your garage.toml
If you already have a running Garage instance, you can point Garage UI straight at your `garage.toml` and skip `config.yaml` entirely:
```bash
./garage-ui --garage-toml /etc/garage.toml
@@ -90,7 +97,7 @@ If you already have a running Garage instance, you can point Garage UI directly
Garage UI reads the S3 endpoint, admin endpoint, admin token, and S3 region straight from the TOML file. When no authentication method is explicitly configured, **token auth auto-enables**: the login page asks for the Garage admin token, giving you a login wall with zero extra config.
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside containers this won't work -- override the endpoint explicitly with environment variables or a config file.
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside a container this won't work, so override the endpoints explicitly with environment variables or a config file.
**Docker:**
@@ -152,7 +159,7 @@ GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
#### Loading sensitive values from files (`_FILE` suffix)
For Docker/Kubernetes secret integration, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui reads the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file causes startup to fail.
For Docker and Kubernetes secrets, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui uses the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file stops startup.
Supported vars:
@@ -179,7 +186,7 @@ secrets:
file: ./admin_password.txt
```
This matches the convention used by the official Postgres and MySQL Docker images. Helm users do not need this — the chart already injects secrets via `existingSecret` references.
This matches the convention used by the official Postgres and MySQL Docker images. Helm users don't need it; the chart already injects secrets via `existingSecret` references.
## Garage Configuration
@@ -234,49 +241,19 @@ logging:
## Roadmap
Ideas being considered. Contributions welcome.
Roughly ordered by value. Open an [issue](https://github.com/Noooste/garage-ui/issues) to push something up the list.
**Object browser**
- [ ] Inline preview (images, PDF, video, text/markdown, code)
- [ ] Resumable multipart uploads with pause/resume
- [ ] Folder uploads preserving prefix structure
- [ ] Bulk actions (delete, copy prefix, download prefix as zip)
- [ ] Command palette (Cmd-K) and keyboard navigation
**Sharing**
- [ ] Presigned download links with expiry + QR code
- [ ] Presigned upload drop-zones ("send me a file" pages)
**Buckets**
- [ ] Bucket alias manager (global vs. user-scoped)
- [ ] Quota editor with live usage bar
- [ ] Lifecycle editor (expiration + abort-multipart)
- [ ] CORS editor with built-in test request
- [ ] Website config (index/error docs) with live link
- [ ] Per-bucket usage graph over time
**Access keys**
- [ ] Permission matrix view (keys x buckets)
- [ ] Key rotation helper
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
**Cluster**
- [X] Support Garage v1 to latest
- [ ] Visual layout editor with staged vs. applied diff
- [ ] Capacity planner / simulation
- [ ] Rebalance progress and node health timeline
- [ ] Worker/repair panel (trigger scrub, repair, rebalance)
**Observability**
- [ ] Dashboard with dedup/compression savings
- [ ] Metrics explorer pulling from Garage `/metrics`
- [ ] Admin audit log
**Polish**
- [ ] i18n (FR/EN)
- [ ] Mobile-friendly object browser
- [ ] First-run onboarding wizard
- [ ] GitOps export (layout + buckets + keys as YAML)
- [x] **Fine-grained access control**: OIDC teams with per-bucket-prefix permissions, see [docs/access-control.md](docs/access-control.md)
- [x] **Object search**: recursive substring search across a bucket
- [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
- [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
- [ ] **Admin audit log**: who changed what, building on access control
- [ ] **Table and detail polish**: sortable columns, clearer node details ([#36](https://github.com/Noooste/garage-ui/issues/36), [#37](https://github.com/Noooste/garage-ui/issues/37))
## License
@@ -286,4 +263,8 @@ MIT - see [LICENSE](LICENSE)
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
---
<p align="center">Made with ❤️ in France 🇫🇷</p>
+209
View File
@@ -0,0 +1,209 @@
// Command seed bulk-loads a Garage/S3 bucket with millions of small objects
// for testing the object browser and search features.
//
// Keys are a deterministic function of a global index, so a run is resumable:
// re-run with -start=<last index> and any re-uploaded key simply overwrites.
// Layout: pets/<species>/<breed>/<name>-<NNNNNN>.dat
//
// cd backend && go run ./cmd/seed # full 3,000,000 objects
// go run ./cmd/seed -count 10000 # quick smoke test
// go run ./cmd/seed -start 1500000 # resume from index 1.5M
package main
import (
"bytes"
"context"
"flag"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// species -> breeds. Real words so keys are searchable by substring
// (e.g. "golden", "retriever", "siamese"). Flattened into speciesBreed at init.
var taxonomy = []struct {
species string
breeds []string
}{
{"dogs", []string{"labrador", "golden-retriever", "german-shepherd", "bulldog", "poodle", "beagle", "rottweiler", "dachshund", "husky", "chihuahua"}},
{"cats", []string{"siamese", "persian", "maine-coon", "bengal", "ragdoll", "sphynx", "british-shorthair", "abyssinian"}},
{"birds", []string{"parrot", "canary", "cockatiel", "budgie", "finch", "macaw", "lovebird"}},
{"fish", []string{"goldfish", "guppy", "betta", "angelfish", "tetra", "molly"}},
{"rabbits", []string{"holland-lop", "netherland-dwarf", "rex", "lionhead", "flemish-giant"}},
{"hamsters", []string{"syrian", "dwarf-campbell", "roborovski", "chinese"}},
{"reptiles", []string{"leopard-gecko", "iguana", "bearded-dragon", "corn-snake", "box-turtle"}},
{"horses", []string{"arabian", "thoroughbred", "mustang", "clydesdale", "appaloosa"}},
{"guinea-pigs", []string{"american", "abyssinian", "peruvian", "silkie"}},
{"ferrets", []string{"sable", "albino", "cinnamon", "chocolate"}},
}
// petNames are the leaf file names. ~50 common pet names.
var petNames = []string{
"buddy", "luna", "max", "bella", "charlie", "lucy", "cooper", "daisy", "rocky", "molly",
"bailey", "sadie", "duke", "maggie", "bear", "sophie", "tucker", "chloe", "oliver", "lola",
"jack", "zoe", "toby", "ruby", "teddy", "rosie", "milo", "gracie", "oscar", "coco",
"leo", "penny", "rex", "willow", "sam", "honey", "gus", "ginger", "murphy", "olive",
"jasper", "hazel", "finn", "ivy", "louie", "pepper", "ziggy", "nala", "apollo", "cleo",
}
type speciesBreedPair struct{ species, breed string }
var speciesBreed []speciesBreedPair
func init() {
for _, t := range taxonomy {
for _, b := range t.breeds {
speciesBreed = append(speciesBreed, speciesBreedPair{t.species, b})
}
}
}
// keyFor maps a global index to a unique object key with even folder fill.
// folderIdx selects the species/breed folder; within selects (name, suffix)
// inside that folder. The mapping is a bijection, so keys never collide.
func keyFor(i int64) string {
c := int64(len(speciesBreed))
n := int64(len(petNames))
folderIdx := i % c
within := i / c
name := petNames[within%n]
suffix := within / n
sb := speciesBreed[folderIdx]
return fmt.Sprintf("pets/%s/%s/%s-%06d.dat", sb.species, sb.breed, name, suffix)
}
func main() {
var (
endpoint = flag.String("endpoint", "localhost:3900", "S3 endpoint host:port")
bucket = flag.String("bucket", "test", "target bucket")
region = flag.String("region", "garage", "S3 region")
accessKey = flag.String("access-key", "GK4b706791e6efb7bc00a99c69", "S3 access key")
secretKey = flag.String("secret-key", "cdb665539872887e4fca34841ad2ebd79cda7af2302b500097262ec030123b14", "S3 secret key")
count = flag.Int64("count", 3_000_000, "total dataset size (upper index, exclusive)")
start = flag.Int64("start", 0, "start index (resume point)")
size = flag.Int("size", 4096, "bytes per object")
concurrency = flag.Int("concurrency", 64, "concurrent upload workers")
secure = flag.Bool("secure", false, "use HTTPS")
)
flag.Parse()
client, err := minio.New(*endpoint, &minio.Options{
Creds: credentials.NewStaticV4(*accessKey, *secretKey, ""),
Secure: *secure,
Region: *region,
BucketLookup: minio.BucketLookupPath, // Garage needs path-style
})
if err != nil {
log.Fatalf("client init: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
total := *count - *start
if total <= 0 {
log.Fatalf("nothing to do: start=%d >= count=%d", *start, *count)
}
log.Printf("seeding bucket %q: indices [%d,%d) = %d objects of %d bytes, concurrency=%d, folders=%d",
*bucket, *start, *count, total, *size, *concurrency, len(speciesBreed))
var (
cursor = *start
done int64
errCount int64
wg sync.WaitGroup
startTime = time.Now()
)
// Progress reporter.
reportDone := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var last int64
lastT := startTime
for {
select {
case <-reportDone:
return
case now := <-ticker.C:
d := atomic.LoadInt64(&done)
cur := atomic.LoadInt64(&cursor)
instRate := float64(d-last) / now.Sub(lastT).Seconds()
last, lastT = d, now
var eta time.Duration
if instRate > 0 {
eta = time.Duration(float64(total-d)/instRate) * time.Second
}
log.Printf("progress: %d/%d (%.1f%%) | %.0f obj/s | errors=%d | next-index=%d | eta=%s",
d, total, 100*float64(d)/float64(total), instRate,
atomic.LoadInt64(&errCount), cur, eta.Round(time.Second))
}
}
}()
opts := minio.PutObjectOptions{ContentType: "application/octet-stream", DisableMultipart: true}
for w := 0; w < *concurrency; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
// One random, incompressible buffer per worker (Garage compresses
// per-object, so reuse is fine and avoids per-object allocation).
buf := make([]byte, *size)
r := rand.New(rand.NewSource(int64(1000 + worker)))
for j := range buf {
buf[j] = byte(r.Intn(256))
}
for {
idx := atomic.AddInt64(&cursor, 1) - 1
if idx >= *count {
return
}
if ctx.Err() != nil {
return
}
key := keyFor(idx)
var putErr error
for attempt := 0; attempt < 3; attempt++ {
_, putErr = client.PutObject(ctx, *bucket, key, bytes.NewReader(buf), int64(*size), opts)
if putErr == nil || ctx.Err() != nil {
break
}
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
}
if putErr != nil {
if n := atomic.AddInt64(&errCount, 1); n <= 10 {
log.Printf("put %q failed: %v", key, putErr)
}
continue
}
atomic.AddInt64(&done, 1)
}
}(w)
}
wg.Wait()
close(reportDone)
elapsed := time.Since(startTime)
d := atomic.LoadInt64(&done)
log.Printf("DONE: uploaded %d/%d objects in %s (%.0f obj/s), errors=%d",
d, total, elapsed.Round(time.Second), float64(d)/elapsed.Seconds(), atomic.LoadInt64(&errCount))
if ctx.Err() != nil {
log.Printf("interrupted; resume with -start=%d", atomic.LoadInt64(&cursor))
}
if atomic.LoadInt64(&errCount) > 0 {
os.Exit(1)
}
}
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// PreviewTokenLocalsKey is the fiber Locals key the auth middleware sets
// after validating a preview token. The authz middleware reads it to
// authorize the request without a subject.
const PreviewTokenLocalsKey = "previewTokenClaims"
// PreviewClaims identify the single object a preview token can read.
type PreviewClaims struct {
Bucket string `json:"b"`
Key string `json:"k"`
ExpiresAt int64 `json:"exp"`
}
// previewSecret derives the HMAC key from the JWT signing key. A configured
// session key therefore keeps preview URLs valid across restarts, and a
// generated key invalidates them on restart, which the frontend recovers
// from by minting a fresh URL.
func (j *JWTService) previewSecret() []byte {
j.mu.RLock()
defer j.mu.RUnlock()
sum := sha256.Sum256(append([]byte("garage-ui-preview-token:"), j.privateKey...))
return sum[:]
}
// MintPreviewToken signs a token granting read access to one exact object
// until the TTL elapses.
func (a *Service) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
expiresAt := time.Now().Add(ttl)
token, err := mintPreviewToken(a.jwtService.previewSecret(), bucket, key, expiresAt)
return token, expiresAt, err
}
// ValidatePreviewToken checks the signature, expiry, and exact object match.
func (a *Service) ValidatePreviewToken(token, bucket, key string) error {
return verifyPreviewToken(a.jwtService.previewSecret(), token, bucket, key, time.Now())
}
func mintPreviewToken(secret []byte, bucket, key string, expiresAt time.Time) (string, error) {
payload, err := json.Marshal(PreviewClaims{Bucket: bucket, Key: key, ExpiresAt: expiresAt.Unix()})
// Defensive and unreachable: marshaling a struct of two strings and an int64
// cannot fail, so this branch stays uncovered by design.
if err != nil {
return "", fmt.Errorf("failed to encode preview claims: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return encoded + "." + signPreview(secret, encoded), nil
}
func verifyPreviewToken(secret []byte, token, bucket, key string, now time.Time) error {
encoded, sig, ok := strings.Cut(token, ".")
if !ok {
return fmt.Errorf("malformed preview token")
}
if !hmac.Equal([]byte(sig), []byte(signPreview(secret, encoded))) {
return fmt.Errorf("invalid preview token signature")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return fmt.Errorf("malformed preview token payload")
}
var claims PreviewClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return fmt.Errorf("malformed preview token claims")
}
if now.Unix() > claims.ExpiresAt {
return fmt.Errorf("preview token expired")
}
if claims.Bucket != bucket || claims.Key != key {
return fmt.Errorf("preview token does not match the requested object")
}
return nil
}
func signPreview(secret []byte, encoded string) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(encoded))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
+122
View File
@@ -0,0 +1,122 @@
package auth
import (
"encoding/base64"
"strings"
"testing"
"time"
)
func newPreviewTestService(t *testing.T) *Service {
t.Helper()
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
return &Service{jwtService: jwtSvc}
}
func TestPreviewToken_RoundTrip(t *testing.T) {
svc := newPreviewTestService(t)
token, expiresAt, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if remaining := time.Until(expiresAt); remaining < 59*time.Minute || remaining > time.Hour {
t.Errorf("expiresAt %v is not about an hour away", expiresAt)
}
if err := svc.ValidatePreviewToken(token, "b1", "dir/clip.mp4"); err != nil {
t.Errorf("ValidatePreviewToken: %v", err)
}
}
func TestPreviewToken_Expired(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", -time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected expired token to be rejected")
}
}
func TestPreviewToken_WrongObject(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k1", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b2", "k1"); err == nil {
t.Error("expected wrong bucket to be rejected")
}
if err := svc.ValidatePreviewToken(token, "b1", "k2"); err == nil {
t.Error("expected wrong key to be rejected")
}
}
func TestPreviewToken_Tampered(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
payload, sig, _ := strings.Cut(token, ".")
flipped := "A" + payload[1:]
if flipped == payload {
flipped = "B" + payload[1:]
}
if err := svc.ValidatePreviewToken(flipped+"."+sig, "b1", "k"); err == nil {
t.Error("expected tampered payload to be rejected")
}
if err := svc.ValidatePreviewToken(payload+".AAAA", "b1", "k"); err == nil {
t.Error("expected tampered signature to be rejected")
}
}
func TestPreviewToken_Malformed(t *testing.T) {
svc := newPreviewTestService(t)
for _, tok := range []string{"", "nodot", "a.b.c", "!!!.???", "bm90anNvbg.sig"} {
if err := svc.ValidatePreviewToken(tok, "b1", "k"); err == nil {
t.Errorf("expected malformed token %q to be rejected", tok)
}
}
}
func TestPreviewToken_DifferentServicesRejectEachOther(t *testing.T) {
a := newPreviewTestService(t)
b := newPreviewTestService(t)
token, _, err := a.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := b.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a token from another key to be rejected")
}
}
// TestPreviewToken_ValidSignatureMalformedBase64Payload signs a payload that
// is not valid RawURLEncoding, so the signature check passes but the base64
// decode fails. This exercises the decode error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureMalformedBase64Payload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := "!!not-base64!!"
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-base64 payload to be rejected")
}
}
// TestPreviewToken_ValidSignatureNonJSONPayload signs a valid base64 payload
// whose bytes are not JSON, so the signature and decode both pass but the
// unmarshal fails. This exercises the JSON error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureNonJSONPayload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := base64.RawURLEncoding.EncodeToString([]byte("not json"))
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-JSON payload to be rejected")
}
}
+9
View File
@@ -103,6 +103,15 @@ func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler
if !m.enabled {
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")
+50
View File
@@ -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
+6 -4
View File
@@ -49,10 +49,11 @@ type GarageConfig struct {
// AuthConfig contains authentication configuration
type AuthConfig struct {
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
Token TokenAuthConfig `mapstructure:"token"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
Token TokenAuthConfig `mapstructure:"token"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth
}
// AdminAuthConfig contains admin authentication settings
@@ -291,6 +292,7 @@ func bindEnvVars() {
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
viper.BindEnv("auth.metrics_public", "GARAGE_UI_AUTH_METRICS_PUBLIC")
// Token auth config
viper.BindEnv("auth.token.enabled", "GARAGE_UI_AUTH_TOKEN_ENABLED")
+46
View File
@@ -894,3 +894,49 @@ func TestIsProduction(t *testing.T) {
})
}
}
func TestLoad_MetricsPublic_DefaultsFalse(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = true, want false by default")
}
}
func TestLoad_MetricsPublic_YAML(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML+`
auth:
metrics_public: true
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = false, want true from YAML")
}
}
func TestLoad_MetricsPublic_EnvOverridesYAML(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML+`
auth:
metrics_public: false
`)
t.Setenv("GARAGE_UI_AUTH_METRICS_PUBLIC", "true")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = false, want true (env should override YAML)")
}
}
+179 -30
View File
@@ -1,7 +1,6 @@
package handlers
import (
"bufio"
"io"
"net/url"
"path"
@@ -68,15 +67,27 @@ func contentDispositionHeader(disposition, key string) string {
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
}
// PreviewTokenMinter mints signed single-object preview tokens.
// auth.Service satisfies it.
type PreviewTokenMinter interface {
MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error)
}
// previewTokenTTL is long enough that seeking mid-playback keeps working.
// The frontend mints a fresh URL when a token expires.
const previewTokenTTL = time.Hour
// ObjectHandler handles object-related HTTP requests.
type ObjectHandler struct {
s3Service services.S3Storage
s3Service services.S3Storage
previewTokens PreviewTokenMinter
}
// NewObjectHandler creates a new object handler.
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMinter) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
s3Service: s3Service,
previewTokens: previewTokens,
}
}
@@ -277,12 +288,8 @@ func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameters
bucketName := c.Params("bucket")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
@@ -294,7 +301,17 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
)
}
// Get object from Garage
// Range requests stream a partial body so media elements can seek.
if rangeHeader := c.Get("Range"); rangeHeader != "" {
return h.getObjectRange(c, bucketName, key, rangeHeader)
}
return h.serveFullObject(c, bucketName, key)
}
// serveFullObject streams the whole object with a 200, the pre-Range behavior.
func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) error {
ctx := c.Context()
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
@@ -307,11 +324,12 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
// cannot run as XSS in the SPA origin when fetched inline.
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// The object key is attacker-controlled — build the header via the safe
// The object key is attacker-controlled. Build the header via the safe
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
disposition := "inline"
if c.Query("download") == "true" {
@@ -319,11 +337,60 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
// Stream the object body to the client without buffering the entire file
return c.SendStreamWriter(func(w *bufio.Writer) {
defer body.Close()
io.Copy(w, body)
})
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
// streaming writer variant forces fasthttp into unknown-length chunked
// transfer, dropping the header we just set above.
return c.SendStream(body, int(objectInfo.Size))
}
// getObjectRange serves a single-range request with 206 Partial Content.
// Malformed and multi-range headers fall back to the full 200 response.
func (h *ObjectHandler) getObjectRange(c fiber.Ctx, bucketName, key, rangeHeader string) error {
ctx := c.Context()
info, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
rng, unsatisfiable := parseRangeHeader(rangeHeader, info.Size)
if unsatisfiable {
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Range", "bytes */"+strconv.FormatInt(info.Size, 10))
return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
}
if rng == nil {
return h.serveFullObject(c, bucketName, key)
}
body, err := h.s3Service.GetObjectRange(ctx, bucketName, key, rng.start, rng.end)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
c.Set("Content-Type", safeContentType(info.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Length", strconv.FormatInt(rng.end-rng.start+1, 10))
c.Set("Content-Range", "bytes "+strconv.FormatInt(rng.start, 10)+"-"+strconv.FormatInt(rng.end, 10)+"/"+strconv.FormatInt(info.Size, 10))
c.Set("ETag", info.ETag)
c.Set("Last-Modified", info.LastModified.Format(time.RFC1123))
disposition := "inline"
if c.Query("download") == "true" {
disposition = "attachment"
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
c.Status(fiber.StatusPartialContent)
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
// streaming writer variant forces fasthttp into unknown-length chunked
// transfer, dropping the header we just set above.
return c.SendStream(body, int(rng.end-rng.start+1))
}
// DeleteObject deletes an object from a bucket
@@ -428,6 +495,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
)
}
c.Set("Accept-Ranges", "bytes")
return c.JSON(models.SuccessResponse(metadata))
}
@@ -512,6 +580,48 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(response))
}
// GetPreviewURL mints a short-lived tokenized URL for streaming this object
//
// @Summary Get a tokenized preview URL for an object
// @Description Returns a relative URL carrying a short-lived token that authorizes streaming this object. Media elements cannot send an Authorization header, so the token rides in the URL instead.
// @Tags Objects
// @Produce json
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Success 200 {object} models.APIResponse{data=models.PreviewURLResponse} "Preview URL minted"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to mint the preview token"
// @Router /api/v1/buckets/{bucket}/objects/{key}/preview-url [get]
func (h *ObjectHandler) GetPreviewURL(c fiber.Ctx) error {
bucketName := c.Params("bucket")
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
token, expiresAt, err := h.previewTokens.MintPreviewToken(bucketName, key, previewTokenTTL)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to mint the preview token: "+err.Error()),
)
}
previewURL := "/api/v1/buckets/" + url.PathEscape(bucketName) +
"/objects/" + url.PathEscape(key) + "?pt=" + url.QueryEscape(token)
return c.JSON(models.SuccessResponse(models.PreviewURLResponse{
URL: previewURL,
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
}))
}
// DeleteMultipleObjects deletes multiple objects from a bucket
//
// @Summary Delete multiple objects from bucket
@@ -519,8 +629,8 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket containing the objects"
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
// @Param bucket path string true "Name of the bucket containing the objects"
// @Param request body object{keys=[]string,prefixes=[]string} true "Object keys to delete and/or folder prefixes to delete recursively"
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
@@ -537,10 +647,11 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
// Parse request body to get keys and optional prefix
// Parse request body. "keys" are concrete objects to delete; "prefixes" are
// folders to delete recursively (every object stored under the prefix).
var req struct {
Keys []string `json:"keys"`
Prefix string `json:"prefix,omitempty"`
Keys []string `json:"keys"`
Prefixes []string `json:"prefixes,omitempty"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -548,23 +659,61 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
if len(req.Keys) == 0 {
if len(req.Keys) == 0 && len(req.Prefixes) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key or prefix is required"),
)
}
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
// Validate and normalize folder prefixes before running an irreversible
// recursive delete on a public endpoint. A blank prefix would match the
// entire bucket, and a prefix without a trailing slash (e.g. "photos/2024")
// would also match sibling keys such as "photos/2024-old/...". Reject blanks
// with a 4XX and force a trailing slash so a prefix only ever deletes the
// objects inside its own folder.
prefixes := make([]string, 0, len(req.Prefixes))
for _, p := range req.Prefixes {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Prefix must not be blank"),
)
}
if !strings.HasSuffix(trimmed, "/") {
trimmed += "/"
}
prefixes = append(prefixes, trimmed)
}
deleted := 0
// Delete the individually selected objects in a single batch call.
if len(req.Keys) > 0 {
n, err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
}
deleted += n
}
// Recursively delete every object under each selected folder prefix.
for _, prefix := range prefixes {
n, err := h.s3Service.DeleteObjectsByPrefix(ctx, bucketName, prefix)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete folder "+prefix+": "+err.Error()),
)
}
deleted += n
}
response := models.ObjectDeleteMultipleResponse{
Bucket: bucketName,
Deleted: len(req.Keys),
Keys: req.Keys,
Bucket: bucketName,
Deleted: deleted,
Keys: req.Keys,
Prefixes: prefixes,
}
return c.JSON(models.SuccessResponse(response))
+434 -6
View File
@@ -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) {
@@ -565,11 +687,11 @@ func TestUploadObject_ServiceError500(t *testing.T) {
func TestDeleteMultipleObjects_Success(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error {
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) (int, error) {
if bucket != "b1" || len(keys) != 3 {
t.Errorf("args = (%q, %v)", bucket, keys)
}
return nil
return len(keys), nil
}
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
@@ -591,6 +713,81 @@ func TestDeleteMultipleObjects_Success(t *testing.T) {
}
}
func TestDeleteMultipleObjects_Prefixes_Recursive(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteObjectsByPrefixFn = func(_ context.Context, bucket, prefix string) (int, error) {
if bucket != "b1" || prefix != "docs/" {
t.Errorf("args = (%q, %q)", bucket, prefix)
}
return 4, nil
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
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", resp.StatusCode)
}
var out struct {
Data models.ObjectDeleteMultipleResponse `json:"data"`
}
decodeJSON(t, resp.Body, &out)
if out.Data.Deleted != 4 {
t.Errorf("Deleted = %d, want 4", out.Data.Deleted)
}
}
func TestDeleteMultipleObjects_KeysAndPrefixes(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, keys []string) (int, error) {
if len(keys) != 2 {
t.Errorf("keys = %v", keys)
}
return len(keys), nil
}
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) { return 3, nil }
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b"}, "prefixes": []string{"docs/"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
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", resp.StatusCode)
}
var out struct {
Data models.ObjectDeleteMultipleResponse `json:"data"`
}
decodeJSON(t, resp.Body, &out)
if out.Data.Deleted != 5 {
t.Errorf("Deleted = %d, want 5 (2 keys + 3 under prefix)", out.Data.Deleted)
}
}
func TestDeleteMultipleObjects_PrefixError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
return 0, errors.New("boom")
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
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)
}
}
func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body, _ := json.Marshal(map[string]any{"keys": []string{}})
@@ -606,6 +803,54 @@ func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
}
}
func TestDeleteMultipleObjects_BlankPrefix400(t *testing.T) {
// A blank/whitespace-only prefix must be rejected with a 4XX before any
// delete is attempted — it would otherwise target the whole bucket.
for _, prefix := range []string{"", " "} {
app, s3 := newObjectsTestApp(t)
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
t.Errorf("DeleteObjectsByPrefix must not be called for blank prefix %q", prefix)
return 0, nil
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{prefix}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("prefix %q: status = %d, want 400", prefix, resp.StatusCode)
}
}
}
func TestDeleteMultipleObjects_PrefixNormalizedToTrailingSlash(t *testing.T) {
// A prefix without a trailing slash must be normalized so it only deletes
// its own folder ("photos/2024/"), not siblings like "photos/2024-old/".
app, s3 := newObjectsTestApp(t)
var gotPrefix string
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, prefix string) (int, error) {
gotPrefix = prefix
return 1, nil
}
body, _ := json.Marshal(map[string]any{"prefixes": []string{"photos/2024"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
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)
}
if gotPrefix != "photos/2024/" {
t.Errorf("prefix passed to service = %q, want %q", gotPrefix, "photos/2024/")
}
}
func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
app, _ := newObjectsTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
@@ -622,7 +867,7 @@ func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") }
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) (int, error) { return 0, errors.New("boom") }
body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -910,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)
}
}
+68
View File
@@ -0,0 +1,68 @@
package handlers
import (
"strconv"
"strings"
)
// byteRange is a resolved, inclusive byte range within an object.
type byteRange struct {
start int64
end int64
}
// parseRangeHeader resolves a Range request header against the object size.
// It supports a single "bytes=" range in its three forms: start-end, start-,
// and -suffix. A nil result with unsatisfiable false means serve the full
// object with 200; absent, malformed, and multi-range headers all land there,
// which RFC 9110 permits. unsatisfiable true means respond 416.
func parseRangeHeader(header string, size int64) (rng *byteRange, unsatisfiable bool) {
spec, ok := strings.CutPrefix(header, "bytes=")
if !ok {
return nil, false
}
spec = strings.TrimSpace(spec)
if strings.Contains(spec, ",") {
return nil, false
}
startStr, endStr, ok := strings.Cut(spec, "-")
if !ok {
return nil, false
}
startStr = strings.TrimSpace(startStr)
endStr = strings.TrimSpace(endStr)
// Suffix form "-n" asks for the last n bytes.
if startStr == "" {
n, err := strconv.ParseInt(endStr, 10, 64)
if err != nil {
return nil, false
}
if n <= 0 || size == 0 {
return nil, true
}
if n > size {
n = size
}
return &byteRange{start: size - n, end: size - 1}, false
}
start, err := strconv.ParseInt(startStr, 10, 64)
if err != nil || start < 0 {
return nil, false
}
if start >= size {
return nil, true
}
if endStr == "" {
return &byteRange{start: start, end: size - 1}, false
}
end, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || end < start {
return nil, false
}
if end >= size {
end = size - 1
}
return &byteRange{start: start, end: end}, false
}
+54
View File
@@ -0,0 +1,54 @@
package handlers
import "testing"
func TestParseRangeHeader(t *testing.T) {
cases := []struct {
name string
header string
size int64
wantStart int64
wantEnd int64
wantRange bool
wantUnsatisfy bool
}{
{name: "absent header serves full", header: "", size: 10, wantRange: false},
{name: "simple range", header: "bytes=2-6", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
{name: "open ended", header: "bytes=500-", size: 1000, wantStart: 500, wantEnd: 999, wantRange: true},
{name: "suffix", header: "bytes=-300", size: 1000, wantStart: 700, wantEnd: 999, wantRange: true},
{name: "suffix larger than object clamps to full", header: "bytes=-5000", size: 1000, wantStart: 0, wantEnd: 999, wantRange: true},
{name: "end clamped to size", header: "bytes=0-99999", size: 100, wantStart: 0, wantEnd: 99, wantRange: true},
{name: "single byte", header: "bytes=0-0", size: 10, wantStart: 0, wantEnd: 0, wantRange: true},
{name: "start beyond size is unsatisfiable", header: "bytes=100-", size: 100, wantUnsatisfy: true},
{name: "suffix zero is unsatisfiable", header: "bytes=-0", size: 100, wantUnsatisfy: true},
{name: "any range on empty object is unsatisfiable", header: "bytes=0-", size: 0, wantUnsatisfy: true},
{name: "suffix on empty object is unsatisfiable", header: "bytes=-5", size: 0, wantUnsatisfy: true},
{name: "multi range ignored", header: "bytes=0-1,3-4", size: 10, wantRange: false},
{name: "non byte unit ignored", header: "items=0-5", size: 10, wantRange: false},
{name: "end before start ignored", header: "bytes=6-2", size: 10, wantRange: false},
{name: "garbage start ignored", header: "bytes=abc-5", size: 10, wantRange: false},
{name: "garbage end ignored", header: "bytes=5-abc", size: 10, wantRange: false},
{name: "garbage suffix ignored", header: "bytes=-abc", size: 10, wantRange: false},
{name: "negative start ignored", header: "bytes=-5-8", size: 10, wantRange: false},
{name: "missing dash ignored", header: "bytes=5", size: 10, wantRange: false},
{name: "whitespace tolerated", header: "bytes= 2-6 ", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rng, unsatisfiable := parseRangeHeader(tc.header, tc.size)
if unsatisfiable != tc.wantUnsatisfy {
t.Fatalf("unsatisfiable = %v, want %v", unsatisfiable, tc.wantUnsatisfy)
}
if tc.wantRange {
if rng == nil {
t.Fatalf("rng = nil, want %d-%d", tc.wantStart, tc.wantEnd)
}
if rng.start != tc.wantStart || rng.end != tc.wantEnd {
t.Errorf("rng = %d-%d, want %d-%d", rng.start, rng.end, tc.wantStart, tc.wantEnd)
}
} else if rng != nil {
t.Errorf("rng = %d-%d, want nil", rng.start, rng.end)
}
})
}
}
+84
View File
@@ -1,6 +1,7 @@
package middleware
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
}
+207
View File
@@ -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)
}
})
}
}
+9 -3
View File
@@ -150,10 +150,16 @@ 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"`
Keys []string `json:"keys"`
Bucket string `json:"bucket"`
Deleted int `json:"deleted"`
Keys []string `json:"keys"`
Prefixes []string `json:"prefixes,omitempty"`
}
// UserListResponse represents a list of users/keys
+15 -1
View File
@@ -49,6 +49,16 @@ func SetupRoutes(
// Auth configuration endpoint (always accessible, no auth required)
app.Get("/auth/config", authHandler.GetAuthConfig)
// Public Prometheus metrics endpoint (no auth), opt-in via auth.metrics_public.
// Registered outside /api/v1 so it bypasses the AuthMiddleware/ResolveSubject
// cascade and the VerifyRouteCoverage fail-closed guard entirely; the
// authenticated /api/v1/monitoring/metrics route is unaffected. Because it is
// registered before the SPA fallback below, Fiber matches it first.
// Protect it at the network layer (NetworkPolicy / trusted scrape network).
if cfg.Auth.MetricsPublic {
app.Get("/metrics", monitoringHandler.GetMetrics)
}
// API v1 group
api := app.Group("/api/v1")
@@ -103,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)
@@ -333,7 +346,8 @@ func SetupRoutes(
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/auth") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") {
strings.HasPrefix(path, "/docs") ||
path == "/metrics" {
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
return c.Next()
}
+72 -1
View File
@@ -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)
}
}
@@ -0,0 +1,93 @@
package routes
import (
"context"
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
)
// Flag off (default): /metrics is not registered, and the authenticated
// /api/v1/monitoring/metrics route still rejects unauthenticated requests.
func TestRoutes_MetricsPublic_Disabled_NotRegistered(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
// MetricsPublic defaults to false.
})
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
}
// Flag on, with admin auth enabled: /metrics serves without credentials, while
// the authenticated /api/v1/monitoring/metrics route still requires auth.
func TestRoutes_MetricsPublic_Enabled_ServesWithoutAuth(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
c.Auth.MetricsPublic = true
})
f.Admin.GetMetricsFn = func(_ context.Context) (string, error) {
return "garage_metric 1", nil
}
resp := expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 200)
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "garage_metric") {
t.Errorf("GET /metrics body = %q, want it to contain the metrics text", string(body))
}
// The /api/v1 route stays gated; the fail-closed guarantee is intact.
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
}
// Route coverage must still pass with the flag on: /metrics is outside /api/v1,
// so VerifyRouteCoverage neither requires a Require handler for it nor errors.
func TestRoutes_MetricsPublic_Enabled_RouteCoverageStillPasses(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
c.Auth.MetricsPublic = true
})
if err := authz.VerifyRouteCoverage(f.App); err != nil {
t.Errorf("VerifyRouteCoverage returned error with metrics_public on: %v", err)
}
}
// With the metrics flag OFF and the SPA frontend present, GET /metrics must
// return 404 (not the SPA index.html), so a misconfigured Prometheus scrape
// fails loudly instead of silently receiving HTML with a 200 status.
func TestRoutes_MetricsPublic_Disabled_WithSPA_Returns404(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// Create ./frontend/dist/index.html so the SPA fallback mounts.
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "frontend", "dist", "index.html"),
[]byte("<!doctype html><title>spa</title>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
// MetricsPublic defaults to false → no /metrics route registered.
})
// SPA fallback is mounted; /metrics must be excluded from it → 404, not
// index.html with 200.
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
}
@@ -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)
+2 -1
View File
@@ -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
+3 -1
View File
@@ -52,11 +52,13 @@ 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)
GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error)
DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error)
UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
@@ -115,7 +115,7 @@ func TestS3Mock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
if _, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err == nil {
t.Error("GetPresignedURL: want error")
}
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
if _, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
t.Error("DeleteMultipleObjects: want error")
}
// UploadMultipleObjects has no error channel; it must return a result slice
@@ -163,7 +163,7 @@ func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
GetPresignedURLFn: func(_ context.Context, _, _ string, _ time.Duration) (string, error) {
return "http://x", nil
},
DeleteMultipleObjectsFn: func(_ context.Context, _ string, _ []string) error { return nil },
DeleteMultipleObjectsFn: func(_ context.Context, _ string, keys []string) (int, error) { return len(keys), nil },
UploadMultipleObjectsFn: func(_ context.Context, _ string, files []struct {
Key string
Body io.Reader
@@ -201,8 +201,8 @@ func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
if u, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err != nil || u == "" {
t.Errorf("GetPresignedURL = (%q, %v)", u, err)
}
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil {
t.Errorf("DeleteMultipleObjects: %v", err)
if n, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil || n != 1 {
t.Errorf("DeleteMultipleObjects = (%d, %v)", n, err)
}
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
+21 -3
View File
@@ -28,11 +28,13 @@ 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)
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) (int, error)
DeleteObjectsByPrefixFn func(ctx context.Context, bucketName, prefix string) (int, error)
UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
@@ -88,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 {
@@ -120,14 +130,22 @@ func (m *S3Mock) GetPresignedURL(ctx context.Context, bucketName, key string, ex
return m.GetPresignedURLFn(ctx, bucketName, key, expiresIn)
}
func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error) {
m.record("DeleteMultipleObjects", bucketName, keys)
if m.DeleteMultipleObjectsFn == nil {
return s3NotConfigured("DeleteMultipleObjects")
return 0, s3NotConfigured("DeleteMultipleObjects")
}
return m.DeleteMultipleObjectsFn(ctx, bucketName, keys)
}
func (m *S3Mock) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
m.record("DeleteObjectsByPrefix", bucketName, prefix)
if m.DeleteObjectsByPrefixFn == nil {
return 0, s3NotConfigured("DeleteObjectsByPrefix")
}
return m.DeleteObjectsByPrefixFn(ctx, bucketName, prefix)
}
func (m *S3Mock) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
+89 -10
View File
@@ -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
@@ -604,16 +632,23 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
}, nil
}
// DeleteMultipleObjects deletes multiple objects from a bucket
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
// DeleteMultipleObjects deletes multiple objects from a bucket and returns the
// number of objects that were removed (requested keys minus any that failed).
//
// Note: S3/MinIO batch delete is idempotent — removing a key that does not
// exist succeeds and is not reported on the error channel, so it counts toward
// the returned total. The count therefore reflects "keys the delete operation
// did not fail on", which is the strongest signal obtainable without a
// per-key existence check.
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error) {
if len(keys) == 0 {
return nil
return 0, nil
}
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Create channel for objects to delete
@@ -629,17 +664,61 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
}()
// Call MinIO RemoveObjects API (batch delete)
// Call MinIO RemoveObjects API (batch delete). RemoveObjects only surfaces
// the objects it FAILED to delete, so we drain the whole channel (which also
// avoids leaking the sender goroutine) and count failures.
errorCh := client.RemoveObjects(ctx, bucketName, objectsCh, minio.RemoveObjectsOptions{})
// Check for errors
for err := range errorCh {
if err.Err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
failed := 0
var firstErr error
for rerr := range errorCh {
if rerr.Err != nil {
failed++
if firstErr == nil {
firstErr = fmt.Errorf("failed to delete object %s from bucket %s: %w", rerr.ObjectName, bucketName, rerr.Err)
}
}
}
return nil
if firstErr != nil {
return len(keys) - failed, firstErr
}
return len(keys), nil
}
// DeleteObjectsByPrefix recursively deletes every object stored under the given
// prefix (i.e. a "folder"), including the directory marker itself. It returns
// the number of objects that were deleted.
func (s *S3Service) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
if prefix == "" {
return 0, fmt.Errorf("prefix is required for recursive delete")
}
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// List every object under the prefix recursively (no delimiter), so nested
// folders are flattened into their concrete keys.
keys := make([]string, 0)
for obj := range client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
}) {
if obj.Err != nil {
return 0, fmt.Errorf("failed to list objects under prefix %s in bucket %s: %w", prefix, bucketName, obj.Err)
}
keys = append(keys, obj.Key)
}
if len(keys) == 0 {
return 0, nil
}
return s.DeleteMultipleObjects(ctx, bucketName, keys)
}
// GetPresignedURL generates a pre-signed URL for temporary access to an object
+171 -3
View File
@@ -274,8 +274,8 @@ func TestS3_DeleteMultipleObjects_EmptyKeysIsNoop(t *testing.T) {
})
s3 := newS3TestService(t, h)
if err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil {
t.Fatalf("empty keys should return nil, got %v", err)
if n, err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil || n != 0 {
t.Fatalf("empty keys should return (0, nil), got (%d, %v)", n, err)
}
if called {
t.Error("S3 handler was invoked for empty-keys call")
@@ -289,12 +289,114 @@ func TestS3_DeleteMultipleObjects_ServerErrorPropagates(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
_, err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
if err == nil {
t.Fatal("expected error, got nil")
}
}
// s3PrefixDeleteHandler serves a ListObjectsV2 response (GET) from listBody and
// a successful multi-object DeleteResult (POST /{bucket}?delete), recording how
// many batch-delete requests were made.
func s3PrefixDeleteHandler(listBody string, deletePosts *int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
*deletePosts++
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?><DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`)
return
}
// Any GET is treated as a ListObjectsV2 request.
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, listBody)
})
}
func TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError(t *testing.T) {
// A blank prefix must be rejected before any network call — it would
// otherwise match (and delete) every object in the bucket.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "")
})
s3 := newS3TestService(t, h)
n, err := s3.DeleteObjectsByPrefix(context.Background(), "b-TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError", "")
if err == nil {
t.Fatal("empty prefix should return an error")
}
if n != 0 {
t.Errorf("count = %d, want 0", n)
}
if called {
t.Error("no S3 request should be made for an empty prefix")
}
}
func TestS3_DeleteObjectsByPrefix_ListsThenDeletes(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "docs/a"}, {Key: "docs/b"}, {Key: "docs/sub/c"},
}
listBody := listBucketResultXML("b", false, "", contents, nil)
deletePosts := 0
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListsThenDeletes", "docs/")
if err != nil {
t.Fatalf("DeleteObjectsByPrefix: %v", err)
}
if n != 3 {
t.Errorf("deleted = %d, want 3 (all objects listed under the prefix)", n)
}
if deletePosts == 0 {
t.Error("expected a batch-delete request to be made")
}
}
func TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero(t *testing.T) {
listBody := listBucketResultXML("b", false, "", nil, nil)
deletePosts := 0
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero", "empty/")
if err != nil {
t.Fatalf("DeleteObjectsByPrefix: %v", err)
}
if n != 0 {
t.Errorf("deleted = %d, want 0", n)
}
if deletePosts != 0 {
t.Errorf("no batch-delete should be made when nothing matches, got %d", deletePosts)
}
}
func TestS3_DeleteObjectsByPrefix_ListErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListErrorPropagates", "docs/")
if err == nil {
t.Fatal("expected the list error to propagate, got nil")
}
}
func TestS3_GetPresignedURL_ReturnsURLWithoutServerCall(t *testing.T) {
// Presign is purely local (no network round-trip). Any handler suffices.
called := false
@@ -567,3 +669,69 @@ func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
}
}
}
func TestGetObjectRange_SendsRangeHeaderAndStreamsBody(t *testing.T) {
bucket := uniqueBucket2(t)
var gotRange string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Range", "bytes 2-6/10")
w.Header().Set("Content-Length", "5")
// The MinIO client parses Last-Modified from the response headers on
// the first Read, so the fake server must set a valid one.
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("23456"))
})
s3 := newS3TestService(t, handler)
body, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 2, 6)
if err != nil {
t.Fatalf("GetObjectRange: %v", err)
}
defer body.Close()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(data) != "23456" {
t.Errorf("body = %q, want %q", data, "23456")
}
if gotRange != "bytes=2-6" {
t.Errorf("Range header = %q, want %q", gotRange, "bytes=2-6")
}
}
func TestGetObjectRange_InvalidRangeRejectedLocally(t *testing.T) {
bucket := uniqueBucket2(t)
var called bool
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
s3 := newS3TestService(t, handler)
// end before start is a caller bug; SetRange rejects it before any request.
if _, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 6, 2); err == nil {
t.Fatal("expected error for inverted range")
}
if called {
t.Error("no S3 request should be sent for an invalid range")
}
}
func TestGetObjectRange_ClientAcquisitionFailurePropagates(t *testing.T) {
bucket := uniqueBucket(t)
mux := http.NewServeMux()
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
})
s3, _ := adminBackedS3(t, mux)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := s3.GetObjectRange(ctx, bucket, "missing", 0, 4); err == nil {
t.Fatal("expected error, got nil")
}
}
+1 -1
View File
@@ -153,7 +153,7 @@ func main() {
// Initialize handlers
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)
+1
View File
@@ -10,6 +10,7 @@ coverage:
ignore:
- "backend/main.go"
- "backend/cmd/"
- "backend/docs/"
- "backend/internal/services/mocks/"
- "backend/**/*_mock.go"
+9 -3
View File
@@ -34,6 +34,12 @@ auth:
# The key is a 64-byte Ed25519 private key
jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
# Expose Prometheus metrics at top-level /metrics WITHOUT authentication.
# Needed for Prometheus to scrape when auth (admin/token/oidc) is enabled.
# WARNING: exposes operational cluster telemetry (no object data or secrets)
# to anyone who can reach the port. Restrict with a NetworkPolicy / firewall.
metrics_public: false # Set to true to serve /metrics unauthenticated
# Admin Authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
@@ -96,11 +102,11 @@ auth:
cookie_http_only: true
cookie_same_site: "lax" # lax, strict, none
# Optional: team-based access control (issue #33).
# Absent -> every authenticated user has full access (historical behavior).
# Optional: team-based access control (see docs/access-control.md).
# Absent -> every authenticated user has full access.
# Present -> default-deny: OIDC users get only what their teams grant; users
# matching no team get 403 everywhere. admin_role users, admin
# password logins, and token logins are always full-admin in v1.
# password logins, and token logins are always full-admin.
# NOTE: this is UI-layer policy, NOT a security boundary. Anyone holding the
# Garage admin token or S3 keys bypasses it entirely.
#
+55 -43
View File
@@ -1,38 +1,40 @@
# Multi-User Access Control
Garage UI can limit what each user sees and does, based on the teams in their OIDC claims.
Garage UI can scope what each user sees and does, based on the teams in their OIDC claims. A typical setup: the backend team manages every `backend-*` bucket, can read the shared ones, and sees nothing else.
It's optional. With no config, every authenticated user has full access, exactly like before.
This is optional. If your config has no `access_control` section, every authenticated user has full access.
## Not a security boundary
Read this before using access control for anything sensitive.
Read this before relying on access control for anything sensitive.
Garage UI talks to Garage with one admin token and one set of S3 keys. Access control lives in the UI only; Garage itself does not enforce it. Anyone holding the underlying admin token or raw S3 keys bypasses it completely.
Garage UI talks to Garage with a single admin token and a single set of S3 keys. Access control is enforced by the UI, not by Garage. Anyone who holds the underlying admin token or raw S3 keys bypasses it completely.
Use it to give teams a convenient, scoped UI. Don't use it as a replacement for real per-tenant credentials or network isolation.
Use it to give each team a convenient, scoped view of the cluster. Don't use it as a substitute for real per-tenant credentials or network isolation.
## Configuration
## Setup
Two settings drive access control:
Get OIDC login working first. Access control only applies to OIDC users, and [config.example.yaml](../config.example.yaml) covers the OIDC settings.
1. `team_attribute_path`: the OIDC claim that lists a user's teams.
2. `access_control`: maps teams to permissions.
From there, it takes two additions to your config:
1. `team_attribute_path` tells Garage UI which OIDC claim lists a user's teams.
2. `access_control` maps those teams to permissions.
```yaml
auth:
oidc:
# Existing keys unchanged. New:
team_attribute_path: "groups" # go-jmespath, same convention as role_attribute_path
# ...your existing OIDC settings...
team_attribute_path: "groups"
access_control: # absent = full access for everyone; present = default-deny
access_control:
presets:
bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update,
bucket.delete, object.write, object.delete]
teams:
- name: backend
claim_values: ["garage-team-backend"] # matched against the team_attribute_path claim
claim_values: ["garage-team-backend"]
bindings:
- bucket_prefixes: ["backend-"]
permissions: ["preset:bucket_owner"]
@@ -41,17 +43,28 @@ access_control: # absent = full access for everyone; present
cluster_permissions: [cluster.status, cluster.health]
```
A few things to know:
Reading the example top to bottom:
- `team_attribute_path` is a [go-jmespath](https://github.com/jmespath/go-jmespath) expression evaluated against the OIDC claims, the same way `role_attribute_path` works. It's required when `access_control.teams` is set and OIDC is on. If it's missing, startup fails with a clear error.
- `presets` are named permission bundles you can reuse across teams. They're optional; you can always list permissions directly.
- A user whose `groups` claim contains `garage-team-backend` lands in the `backend` team.
- That team owns buckets starting with `backend-`, can read buckets starting with `shared-`, and can view cluster status and health.
- Everyone else, including OIDC users who match no team, gets a 403 on everything. The moment `access_control` exists, the UI switches to default-deny.
A few things to know before writing your own:
- `team_attribute_path` is a [go-jmespath](https://github.com/jmespath/go-jmespath) expression evaluated against the OIDC claims, the same convention as `role_attribute_path`. It's required whenever `access_control.teams` is set and OIDC is enabled. If it's missing, the server refuses to start and the error says why.
- `access_control` can only be set in the config file. There's no environment variable for it, because nested team and binding lists don't fit flat `GARAGE_UI_*` variables.
- If `access_control` is present but OIDC is off, the server still starts but logs a warning. Without OIDC users the policy gates nothing, since admin-password and token logins are always full admin (see [Admin model](#admin-model)).
- If `access_control` is present but OIDC is disabled, the server still starts but logs a warning. The policy would gate nothing, since admin-password and token logins are always full admin (see [Admins](#admins)).
## How it works
### Check that it works
Log in as a test user and open `GET /api/v1/capabilities`. The `access_control` block in the response shows the user's resolved `bindings` and `cluster_permissions`. Empty arrays mean the user matched no team; [Troubleshooting](#troubleshooting) covers the usual reasons.
## How permissions are resolved
### Default-deny
With `access_control` set, an OIDC user who matches no team gets a 403 on every `/api/v1` endpoint. The one exception is `GET /api/v1/capabilities`, which returns their (empty) permissions so the frontend can show a "no access" screen.
Once `access_control` is set, an OIDC user who matches no team gets a 403 on every `/api/v1` endpoint. The one exception is `GET /api/v1/capabilities`, which returns their (empty) permissions so the frontend can show a "no access" screen.
### Union of teams
@@ -61,7 +74,7 @@ Bindings stay separate, though. Say one binding grants `read` on `backend-*` and
### Prefix match
`bucket_prefixes` are plain string prefixes on bucket names (no globbing on the name itself). Use `"*"` to match every bucket.
`bucket_prefixes` are plain string prefixes on bucket names, with no globbing on the name itself. Use `"*"` to match every bucket.
### Presets
@@ -69,21 +82,21 @@ Reference a preset with the `preset:` prefix inside any `permissions` or `cluste
### Permission globs
A trailing-star glob like `bucket.*`, `object.*`, or `cluster.layout.*` expands against the permission vocabulary when config loads. Use scoped globs:
A trailing-star glob like `bucket.*`, `object.*`, or `cluster.layout.*` expands against the permission vocabulary when the config loads. Keep globs inside their scope:
- `bucket.*`, `object.*` inside a binding's `permissions`
- `cluster.*`, `node.*`, `worker.*`, `block.*` under `cluster_permissions`
- `bucket.*` and `object.*` go in a binding's `permissions`
- `cluster.*`, `node.*`, `worker.*`, and `block.*` go under `cluster_permissions`
A bare `*` is technically a glob, but it almost always fails validation: it mixes prefix-scoped and global-scoped permissions, and a permission placed in the wrong scope is rejected at startup. Globs never include admin-only permissions, and in v1 there's no team-level way to grant those.
A bare `*` is technically a glob, but it almost always fails validation: it mixes prefix-scoped and global-scoped permissions, and a permission placed in the wrong scope is rejected at startup. Globs never expand to admin-only permissions, and there's no way to grant those to a team.
### Admin model
### Admins
These identities become a synthetic admin:
- OIDC users with a configured `admin_role` / `admin_roles`
- all non-OIDC logins (admin-password, Garage admin token)
- OIDC users holding a configured `admin_role` / `admin_roles`
- all non-OIDC logins (admin password, Garage admin token)
An admin gets every permission on every bucket, plus every cluster permission. Admins run through the same authorizer as any team; there's no `IsAdmin` shortcut that skips the check.
An admin gets every permission on every bucket, plus every cluster permission. Admins go through the same authorizer as any team; there's no `IsAdmin` shortcut that skips the check.
### Startup validation
@@ -91,18 +104,18 @@ The server refuses to start when the policy is invalid: an unknown permission, a
It also refuses to start if any `/api/v1` route has no declared permission, so a route can never ship un-gated (see [Troubleshooting](#troubleshooting)).
## Not in v1
## Current limitations
- **No non-OIDC team mapping.** Admin-password and Garage-admin-token logins are always full admin. Only OIDC users can be scoped to a team.
- **`ListKeys` is not filtered.** Anyone with `key.list` sees every access key. Everything past `key.list` / `key.read` is admin-only (`key.read_secret`, `key.create`, `key.import`, `key.update`, `key.delete`).
- **Only OIDC users can be scoped to a team.** Admin-password and Garage-admin-token logins are always full admin.
- **`ListKeys` is not filtered.** Anyone with `key.list` sees every access key. Everything past `key.list` and `key.read` is admin-only: `key.read_secret`, `key.create`, `key.import`, `key.update`, `key.delete`.
- **No `admin_token.*` permissions.** Direct access to the raw Garage admin token is admin-only and not part of the vocabulary.
- **No ABAC, policy language, database-backed policy, or per-user grants.** Policy is YAML, compiled once at startup.
## Permission vocabulary (v1)
## Permission reference
Permission names are lowercase and dot-separated: two segments, or three for `cluster.layout.*`. The source of truth is `backend/internal/authz/vocabulary.go`. This table mirrors it by hand, and there's no doc generation in v1, so update the table whenever you change the registry.
Permission names are lowercase and dot-separated: two segments, or three for `cluster.layout.*`. The source of truth is `backend/internal/authz/vocabulary.go`; this table is maintained by hand, so update it whenever the registry changes.
| Permission | Scope | Admin-only v1 | Garage endpoint / backing |
| Permission | Scope | Admin-only | Garage endpoint / backing |
|---|---|---|---|
| `bucket.list` | prefix | | ListBuckets (response-filtered) |
| `bucket.read` | prefix | | GetBucketInfo |
@@ -119,7 +132,7 @@ Permission names are lowercase and dot-separated: two segments, or three for `cl
| `object.delete` | prefix | | S3 data plane (Delete, DeleteMultiple) |
| `permission.allow_bucket_key` | prefix | | AllowBucketKey |
| `permission.deny_bucket_key` | prefix | | DenyBucketKey |
| `key.list` | global | | ListKeys (unfiltered in v1; grantee sees all keys) |
| `key.list` | global | | ListKeys (unfiltered; grantee sees all keys) |
| `key.read` | global | | GetKeyInfo (without secret) |
| `key.read_secret` | global | yes | GetKeyInfo with secret material |
| `key.create` | global | yes | CreateKey |
@@ -145,24 +158,23 @@ Permission names are lowercase and dot-separated: two segments, or three for `cl
| `block.list_errors` | global | | ListBlockErrors |
| `block.info` | global | | GetBlockInfo |
Some permissions have no UI route yet: `bucket.cleanup_uploads`, `bucket.inspect_object`, `bucket_alias.*`, `cluster.layout.*`, `worker.*`, `block.*`, `cluster.connect_nodes`, `node.snapshot`, `node.repair`, and `key.import`. They're valid in config but don't gate anything in the UI yet. The vocabulary is complete up front so your config keeps working as the UI grows.
Some permissions have no UI route yet: `bucket.cleanup_uploads`, `bucket.inspect_object`, `bucket_alias.*`, `cluster.layout.*`, `worker.*`, `block.*`, `cluster.connect_nodes`, `node.snapshot`, `node.repair`, and `key.import`. They're valid in config but don't gate anything in the UI so far. The vocabulary is complete up front so your config keeps working as the UI grows.
Dangerous operations (`cluster.layout.apply`, `node.repair`, `worker.set_variable`) are separate, individually grantable permissions. They're never bundled into a read-only preset, so you can give a team cluster visibility without also giving it the power to break the cluster.
`POST /api/v1/buckets/:name/permissions` sets permissions by doing an allow and a deny in one call, so it needs **both** `permission.allow_bucket_key` and `permission.deny_bucket_key`. One of the two alone is not enough.
One endpoint needs two permissions: `POST /api/v1/buckets/:name/permissions` performs an allow and a deny in a single call, so it requires both `permission.allow_bucket_key` and `permission.deny_bucket_key`. One of the two alone is not enough.
## Troubleshooting
**A user gets 403s they shouldn't.** Open `GET /api/v1/capabilities` while logged in as that user. The `access_control` block shows their resolved `bindings` and `cluster_permissions` (empty arrays mean they matched no team). Check that the IdP actually sends the claim named by `team_attribute_path`, and that its values match a team's `claim_values` exactly (string match, no wildcards on the claim value).
- **A user gets 403s they shouldn't.** Open `GET /api/v1/capabilities` while logged in as that user. The `access_control` block shows their resolved `bindings` and `cluster_permissions` (empty arrays mean they matched no team). Check that the IdP actually sends the claim named by `team_attribute_path`, and that its values match a team's `claim_values` exactly. It's a plain string match, with no wildcards on the claim value.
**403 responses name the missing permission.** The message is `Missing permission: <permission.name>`. That's the exact permission that was denied, so you know which binding, preset, or `cluster_permissions` entry to add.
- **403 responses name the missing permission.** The message is `Missing permission: <permission.name>`. That's the exact permission that was denied, so you know which binding, preset, or `cluster_permissions` entry to add.
**Decision logs.** Every check logs one line, `authz_decision`, with fields `subject`, `action`, `resource`, `decision` (`allow` / `deny`), and `reason` (such as `binding_match`, `any_binding`, `no_matching_binding`, `cluster_permission`, `no_cluster_permission`, `no_subject`). Denials log at `warn`, allows at `debug`. Set `logging.level` to `debug` to see successful checks too. There's no separate audit log in v1; this goes through the normal application logger.
- **Decision logs.** Every check logs one line, `authz_decision`, with fields `subject`, `action`, `resource`, `decision` (`allow` / `deny`), and `reason` (such as `binding_match`, `any_binding`, `no_matching_binding`, `cluster_permission`, `no_cluster_permission`, `no_subject`). Denials log at `warn`, allows at `debug`. Set `logging.level` to `debug` to see successful checks too. There's no separate audit log; this goes through the normal application logger.
**Startup fails with `access_control: ...` or `authz: routes without Require permission declaration: ...`.** Both are intentional fail-closed checks, not bugs:
- An invalid policy (unknown permission, bad preset reference, admin-only permission handed to a team, duplicate team name, empty `claim_values`, or a team with no bindings or cluster permissions) stops startup with an error naming the problem.
- A `/api/v1` route wired without a permission requirement also stops startup. This is a build-time safety net, not something you trigger by editing config, but it can show up after a `git pull` that adds a route without its enforcement wiring.
- **Startup fails with `access_control: ...` or `authz: routes without Require permission declaration: ...`.** Both are intentional fail-closed checks, not bugs:
- An invalid policy (unknown permission, bad preset reference, admin-only permission handed to a team, duplicate team name, empty `claim_values`, or a team with no bindings or cluster permissions) stops startup with an error naming the problem.
- A `/api/v1` route wired without a permission requirement also stops startup. This is a safety net for developers, not something you trigger by editing config, but it can show up after a `git pull` that adds a route without its enforcement wiring.
## See also
+1312 -7
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -7,17 +7,21 @@
"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",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.16.0",
"axios": "^1.18.0",
"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"
}
}
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { AlertTriangle, Check, Database, ShieldCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogBody,
@@ -12,18 +13,67 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { CredentialField } from '@/components/ui/credential-field';
import type { CreateBucketResult, KeyPermissions, NewKeyRequest } from '@/lib/create-bucket-with-key';
import { toast } from 'sonner';
interface CreateBucketDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBucket: (name: string) => Promise<boolean>;
onCreateBucket: (name: string, key?: NewKeyRequest) => Promise<CreateBucketResult>;
canCreateKey: boolean;
}
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
const DEFAULT_PERMISSIONS: KeyPermissions = { read: true, write: true, owner: false };
useEffect(() => { if (!open) setBucketName(''); }, [open]);
const PERMISSION_ROWS = [
{ field: 'read', label: 'Read', desc: 'GetObject, HeadObject, ListObjects' },
{ field: 'write', label: 'Write', desc: 'PutObject, DeleteObject' },
{ field: 'owner', label: 'Owner', desc: 'DeleteBucket, PutBucketPolicy' },
] as const;
function StatusRow({ ok, children }: { ok: boolean; children: ReactNode }) {
return (
<li className="flex items-start gap-2 text-[13.5px]">
{ok ? (
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--primary)]" />
) : (
<X className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" />
)}
<span className="flex-1">{children}</span>
</li>
);
}
export function CreateBucketDialog({
open,
onOpenChange,
onCreateBucket,
canCreateKey,
}: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
const [withKey, setWithKey] = useState(false);
const [keyName, setKeyName] = useState('');
const [permissions, setPermissions] = useState<KeyPermissions>(DEFAULT_PERMISSIONS);
const [creating, setCreating] = useState(false);
const [result, setResult] = useState<CreateBucketResult | null>(null);
// Closing resets the form here rather than in an effect on `open`: Cancel,
// Done, the close button, the backdrop and Escape all route through Dialog's
// onOpenChange, so this is the single place a close can happen.
const handleOpenChange = (next: boolean) => {
if (!next) {
setBucketName('');
setWithKey(false);
setKeyName('');
setPermissions(DEFAULT_PERMISSIONS);
setCreating(false);
setResult(null);
}
onOpenChange(next);
};
const derivedKeyName = `${bucketName || 'my-bucket'}-key`;
const handleCreate = async () => {
if (!bucketName) {
@@ -31,15 +81,96 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
return;
}
const success = await onCreateBucket(bucketName);
if (success) {
setBucketName('');
onOpenChange(false);
setCreating(true);
try {
const outcome = await onCreateBucket(
bucketName,
withKey && canCreateKey ? { name: keyName || derivedKeyName, permissions } : undefined,
);
// Which panel to show follows from the outcome, not from the form state.
if (outcome.bucket === 'failed') return; // the axios interceptor already toasted
if (outcome.key || outcome.keyError) {
setResult(outcome);
return;
}
handleOpenChange(false);
} finally {
setCreating(false);
}
};
if (result) {
const degraded = !!result.keyError || !!result.grantError;
return (
<Dialog open={open} onOpenChange={handleOpenChange} size="form">
<DialogContent>
<DialogHeader>
<IconTile
icon={degraded ? <AlertTriangle /> : <ShieldCheck />}
tone="primary"
size="md"
/>
<div className="min-w-0 flex-1">
<DialogTitle>{result.key ? 'Bucket and key created' : 'Bucket created'}</DialogTitle>
<DialogDescription>
{result.key
? 'Copy your secret access key now, this is the only time it will be shown.'
: 'The bucket is ready, but the access key was not created.'}
</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-5">
<ul className="space-y-2">
<StatusRow ok>
Bucket <span className="font-mono">{bucketName}</span> created
</StatusRow>
{result.key ? (
<StatusRow ok>
Access key <span className="font-mono">{result.key.name}</span> created
</StatusRow>
) : (
<StatusRow ok={false}>
Access key could not be created. You can create one from Access control.
</StatusRow>
)}
{result.key && (
<StatusRow ok={!result.grantError}>
{result.grantError
? 'Permissions were not applied on the bucket. Grant them from Access control.'
: 'Permissions granted on the bucket'}
</StatusRow>
)}
</ul>
{result.key && (
<>
<CredentialField label="Access Key ID" value={result.key.accessKeyId} breakAll />
<CredentialField label="Secret Access Key" value={result.key.secretKey} breakAll />
<div className="flex gap-3 rounded-lg border border-[var(--accent-primary-border)] bg-[var(--accent-primary-soft)] px-3.5 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--primary)]" />
<div className="space-y-0.5">
<p className="text-[13.5px] font-medium text-[var(--foreground)]">
Save this key now
</p>
<p className="text-[12.5px] leading-[1.5] text-[var(--muted-foreground)]">
The secret access key cannot be retrieved again. If lost, you'll need to create a new key.
</p>
</div>
</div>
</>
)}
</DialogBody>
<DialogFooter>
<Button onClick={() => handleOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange} size="form">
<DialogContent>
<DialogHeader>
<IconTile icon={<Database />} tone="primary" size="md" />
@@ -50,10 +181,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-4">
<DialogBody className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<label htmlFor="new-bucket-name" className="text-sm font-medium">
Bucket Name
</label>
<Input
id="new-bucket-name"
autoFocus
placeholder="my-bucket-name"
value={bucketName}
@@ -68,17 +202,78 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
Must be unique and follow DNS naming conventions
</p>
</div>
{canCreateKey && (
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
checked={withKey}
className="mt-0.5"
onCheckedChange={(checked) => setWithKey(checked as boolean)}
/>
<div className="flex-1">
<div className="text-[13.5px] font-medium">Also create an access key</div>
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
Optional, an S3 key scoped to this bucket. You can also do this later from Access control.
</p>
</div>
</label>
{withKey && (
<div className="space-y-4 border-t border-[var(--border)] pt-4">
<div className="space-y-1.5">
<label htmlFor="new-bucket-key-name" className="text-[13px] font-medium">
Key name
</label>
<Input
id="new-bucket-key-name"
placeholder={derivedKeyName}
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
/>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
Leave empty to use {derivedKeyName}.
</p>
</div>
<div className="space-y-1.5">
<label className="text-[13px] font-medium">Permissions</label>
<div className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
{PERMISSION_ROWS.map((p) => (
<label
key={p.field}
htmlFor={`new-bucket-key-${p.field}`}
className="flex cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[var(--accent)]"
>
<Checkbox
id={`new-bucket-key-${p.field}`}
checked={permissions[p.field]}
className="mt-0.5"
onCheckedChange={(checked) =>
setPermissions((prev) => ({ ...prev, [p.field]: checked as boolean }))
}
/>
<div className="flex-1">
<div className="text-[13.5px] font-medium">{p.label}</div>
<p className="mt-0.5 font-mono text-[12px] text-[var(--muted-foreground)]">
{p.desc}
</p>
</div>
</label>
))}
</div>
</div>
</div>
)}
</div>
)}
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreate}
disabled={!bucketName}
>
Create
<Button variant="primary" onClick={handleCreate} disabled={!bucketName || creating}>
{creating ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,6 +5,7 @@ import {Input} from '@/components/ui/input';
import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
import {ConfirmDialog} from '@/components/ui/confirm-dialog';
import {UploadProgress} from './UploadProgress';
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, ScanSearch, Search, Trash, Upload} from 'lucide-react';
import {getBreadcrumbs} from '@/lib/file-utils';
@@ -28,7 +29,7 @@ interface ObjectBrowserViewProps {
onUploadFiles?: (files: File[]) => Promise<boolean>;
uploadTasks: UploadTask[];
onDeleteObject?: (key: string) => Promise<boolean>;
onDeleteMultipleObjects?: (keys: string[]) => Promise<boolean>;
onDeleteMultipleObjects?: (keys: string[], prefixes?: string[]) => Promise<boolean>;
onCreateDirectory?: (name: string) => Promise<boolean>;
onRefresh: () => Promise<void>;
onPageChange: (token?: string) => void;
@@ -72,6 +73,10 @@ export function ObjectBrowserView({
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
const [selectedFolderKeys, setSelectedFolderKeys] = useState<Set<string>>(new Set());
// Holds the keys/prefixes awaiting confirmation in the bulk-delete dialog.
const [pendingDelete, setPendingDelete] = useState<{ keys: string[]; prefixes: string[] } | null>(null);
const [bulkDeleting, setBulkDeleting] = useState(false);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles, _fileRejections, event) => {
@@ -133,33 +138,88 @@ export function ObjectBrowserView({
});
};
const selectedCount = selectedFileKeys.size + selectedFolderKeys.size;
const toggleInSet = (set: Set<string>, key: string) => {
const next = new Set(set);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
};
const handleToggleFileSelection = (key: string) => {
const newSelected = new Set(selectedFileKeys);
if (newSelected.has(key)) {
newSelected.delete(key);
} else {
newSelected.add(key);
}
setSelectedFileKeys(newSelected);
setSelectedFileKeys(prev => toggleInSet(prev, key));
};
const handleSelectAllFiles = () => {
const fileKeys = objects
.filter(obj => !obj.isFolder)
.map(obj => obj.key);
const handleToggleFolderSelection = (key: string) => {
setSelectedFolderKeys(prev => toggleInSet(prev, key));
};
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
setSelectedFileKeys(new Set());
// Select/deselect the currently visible (filtered) rows. The table passes the
// keys it is actually showing so this stays aligned with the search filter
// instead of operating on the full, unfiltered object list.
const handleSelectAll = (fileKeys: string[], folderKeys: string[]) => {
const allVisibleSelected =
fileKeys.length + folderKeys.length > 0 &&
fileKeys.every(k => selectedFileKeys.has(k)) &&
folderKeys.every(k => selectedFolderKeys.has(k));
if (allVisibleSelected) {
// Drop only the visible rows, leaving any off-screen selection intact.
setSelectedFileKeys(prev => {
const next = new Set(prev);
fileKeys.forEach(k => next.delete(k));
return next;
});
setSelectedFolderKeys(prev => {
const next = new Set(prev);
folderKeys.forEach(k => next.delete(k));
return next;
});
} else {
setSelectedFileKeys(new Set(fileKeys));
setSelectedFileKeys(prev => new Set([...prev, ...fileKeys]));
setSelectedFolderKeys(prev => new Set([...prev, ...folderKeys]));
}
};
const handleBulkDeleteFiles = async () => {
if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return;
// Open the confirmation dialog for the current multi-selection.
const handleRequestBulkDelete = () => {
if (selectedCount === 0) return;
setPendingDelete({
keys: Array.from(selectedFileKeys),
prefixes: Array.from(selectedFolderKeys),
});
};
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
setSelectedFileKeys(new Set());
// Open the confirmation dialog for a single folder (recursive delete).
const handleDeleteFolder = (folderKey: string) => {
setPendingDelete({ keys: [], prefixes: [folderKey] });
};
const handleConfirmBulkDelete = async () => {
if (!pendingDelete || !onDeleteMultipleObjects) return;
setBulkDeleting(true);
const success = await onDeleteMultipleObjects(pendingDelete.keys, pendingDelete.prefixes);
setBulkDeleting(false);
if (success) {
// Drop the deleted folders/files from the live selection.
setSelectedFileKeys(prev => {
const next = new Set(prev);
pendingDelete.keys.forEach(k => next.delete(k));
return next;
});
setSelectedFolderKeys(prev => {
const next = new Set(prev);
pendingDelete.prefixes.forEach(k => next.delete(k));
return next;
});
setPendingDelete(null);
}
};
const handleDeleteObject = async (key: string): Promise<boolean> => {
@@ -237,14 +297,14 @@ export function ObjectBrowserView({
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
{onDeleteMultipleObjects && selectedFileKeys.size > 0 && (
{onDeleteMultipleObjects && selectedCount > 0 && (
<Button
onClick={handleBulkDeleteFiles}
title={`Delete ${selectedFileKeys.size} selected file(s)`}
onClick={handleRequestBulkDelete}
title={`Delete ${selectedCount} selected item(s)`}
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"
>
<Trash className="h-4 w-4" />
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
Delete {selectedCount} item{selectedCount !== 1 ? 's' : ''}
</Button>
)}
{onUploadFiles && (
@@ -387,6 +447,7 @@ export function ObjectBrowserView({
filterQuery={filterQuery}
deepSearch={deepSearch}
selectedFileKeys={selectedFileKeys}
selectedFolderKeys={selectedFolderKeys}
isDragActive={isDragActive}
isLoading={isLoading && !isRefreshing && !isNavigating}
isTruncated={isTruncated}
@@ -397,8 +458,10 @@ export function ObjectBrowserView({
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
} : undefined}
onDeleteFolder={onDeleteMultipleObjects ? (obj) => handleDeleteFolder(obj.key) : undefined}
onToggleFileSelection={handleToggleFileSelection}
onSelectAllFiles={handleSelectAllFiles}
onToggleFolderSelection={handleToggleFolderSelection}
onSelectAll={handleSelectAll}
onPageChange={onPageChange}
onItemsPerPageChange={onItemsPerPageChange}
initialPageToken={initialPageToken}
@@ -424,6 +487,53 @@ export function ObjectBrowserView({
object={selectedObject}
onDeleteObject={handleDeleteObject}
/>
{/* Bulk / Folder Delete Confirmation */}
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open && !bulkDeleting) setPendingDelete(null);
}}
title={getBulkDeleteTitle(pendingDelete)}
description={getBulkDeleteDescription(pendingDelete)}
confirmLabel="Delete"
loading={bulkDeleting}
onConfirm={handleConfirmBulkDelete}
/>
</div>
);
}
// Builds a concise title summarising what the bulk-delete dialog will remove.
function getBulkDeleteTitle(pending: { keys: string[]; prefixes: string[] } | null): string {
if (!pending) return 'Delete items?';
const { keys, prefixes } = pending;
const total = keys.length + prefixes.length;
if (keys.length === 0 && prefixes.length === 1) {
return 'Delete folder?';
}
return `Delete ${total} item${total !== 1 ? 's' : ''}?`;
}
// Spells out the file/folder counts and warns that folders are removed recursively.
function getBulkDeleteDescription(
pending: { keys: string[]; prefixes: string[] } | null,
): string {
if (!pending) return '';
const { keys, prefixes } = pending;
const parts: string[] = [];
if (keys.length > 0) {
parts.push(`${keys.length} file${keys.length !== 1 ? 's' : ''}`);
}
if (prefixes.length > 0) {
parts.push(`${prefixes.length} folder${prefixes.length !== 1 ? 's' : ''}`);
}
const summary = parts.join(' and ');
if (prefixes.length > 0) {
return `This will permanently delete ${summary}. Every object stored inside the selected folder${
prefixes.length !== 1 ? 's' : ''
} will be removed recursively.`;
}
return `This will permanently delete ${summary}.`;
}
@@ -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">
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
{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} />;
}
}
@@ -25,15 +25,22 @@ interface ObjectsTableProps {
filterQuery: string;
deepSearch: boolean;
selectedFileKeys: Set<string>;
selectedFolderKeys: Set<string>;
isDragActive: boolean;
isLoading?: boolean;
isTruncated?: boolean;
nextContinuationToken?: string;
itemsPerPage: number;
onNavigateToFolder: (key: string) => void;
// Optional so the parent can withhold them when the user lacks delete
// permission; canDelete (below) is derived from onDeleteObject.
onDeleteObject?: (object: S3Object) => void;
onDeleteFolder?: (object: S3Object) => void;
onToggleFileSelection: (key: string) => void;
onSelectAllFiles: () => void;
onToggleFolderSelection: (key: string) => void;
// Receives the keys of the currently *visible* (filtered) rows so selection
// stays aligned with what the search is actually showing.
onSelectAll: (fileKeys: string[], folderKeys: string[]) => void;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
initialPageToken?: string;
@@ -51,6 +58,7 @@ export function ObjectsTable({
filterQuery,
deepSearch,
selectedFileKeys,
selectedFolderKeys,
isDragActive,
isLoading = false,
isTruncated = false,
@@ -58,8 +66,10 @@ export function ObjectsTable({
itemsPerPage,
onNavigateToFolder,
onDeleteObject,
onDeleteFolder,
onToggleFileSelection,
onSelectAllFiles,
onToggleFolderSelection,
onSelectAll,
onPageChange,
onItemsPerPageChange,
initialPageToken,
@@ -218,12 +228,23 @@ export function ObjectsTable({
{canDelete && (
<TableHead className="w-[50px]">
<Checkbox
// Scope select-all to the rows actually on screen (pageObjects).
// In normal/prefix browsing this equals filteredObjects; in
// client-paginated deep search it is just the visible page, so
// one click never selects hidden matches for a destructive delete.
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
pageObjects.length > 0 &&
pageObjects.every(obj =>
obj.isFolder ? selectedFolderKeys.has(obj.key) : selectedFileKeys.has(obj.key),
)
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
onCheckedChange={() =>
onSelectAll(
pageObjects.filter(obj => !obj.isFolder).map(obj => obj.key),
pageObjects.filter(obj => obj.isFolder).map(obj => obj.key),
)
}
aria-label="Select all objects"
/>
</TableHead>
)}
@@ -277,10 +298,9 @@ export function ObjectsTable({
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
checked={selectedFolderKeys.has(obj.key)}
onCheckedChange={() => onToggleFolderSelection(obj.key)}
aria-label={`Select folder ${obj.key} (deletes its contents recursively)`}
/>
) : (
<Checkbox
@@ -379,7 +399,33 @@ export function ObjectsTable({
})() : null}
</TableCell>
<TableCell>
{!obj.isFolder && (
{obj.isFolder ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onNavigateToFolder(obj.key)}>
<FolderIcon className="h-4 w-4" />
Open
</DropdownMenuItem>
{onDeleteFolder && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteFolder(obj)}
>
<Trash2 className="h-4 w-4" />
Delete folder
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { Check, Copy, Eye, EyeOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
export function CredentialField({
label,
value,
mono = true,
breakAll = false,
maskable = false,
loading = false,
}: {
label: string;
value: string;
mono?: boolean;
breakAll?: boolean;
maskable?: boolean;
loading?: boolean;
}) {
const [copied, setCopied] = useState(false);
const [revealed, setRevealed] = useState(!maskable);
const copy = () => {
if (!value) return;
navigator.clipboard.writeText(value);
setCopied(true);
toast.success(`${label} copied`);
setTimeout(() => setCopied(false), 1600);
};
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
{label}
</label>
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={copy}
disabled={loading || !value}
title="Click to copy"
className={cn(
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
mono && 'font-mono',
breakAll ? 'break-all' : 'truncate',
)}
>
{loading ? (
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</span>
) : (
display
)}
</button>
{maskable && (
<Button
variant="secondary"
size="icon"
onClick={() => setRevealed((r) => !r)}
aria-label={revealed ? 'Hide' : 'Reveal'}
disabled={loading || !value}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
}
+20 -26
View File
@@ -1,6 +1,7 @@
import * as React from 'react';
import {createPortal} from 'react-dom';
import {cn} from '@/lib/utils';
import {placeUnder} from '@/lib/popup-position';
interface DropdownMenuContextValue {
open: boolean;
@@ -62,36 +63,30 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
({ className, children, align = 'start', ...props }) => {
const { open, setOpen, triggerRef } = useDropdownMenu();
const contentRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState({ top: 0, left: 0 });
const [position, setPosition] = React.useState<React.CSSProperties>({});
// Positioned in viewport coordinates against the trigger, since the menu is
// portalled to the body and rendered fixed.
React.useLayoutEffect(() => {
if (!open) return;
// Calculate position based on trigger element
React.useEffect(() => {
const updatePosition = () => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
let left = rect.left + scrollX;
const top = rect.bottom + scrollY + 8; // 8px gap (mt-2)
// Adjust horizontal alignment
if (align === 'end') {
left = rect.right + scrollX - 224; // 224px = w-56
} else if (align === 'center') {
left = rect.left + scrollX + (rect.width / 2) - 112; // 112px = half of w-56
}
setPosition({ top, left });
let left = rect.left;
if (align === 'end') {
left = rect.right - 224; // 224px = w-56
} else if (align === 'center') {
left = rect.left + rect.width / 2 - 112; // 112px = half of w-56
}
setPosition({ left, ...placeUnder(rect, window.innerHeight, 8) });
};
updatePosition();
if (open) {
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
}
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
@@ -126,11 +121,10 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
style={{
backgroundColor: 'var(--popover)',
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
...position,
}}
className={cn(
'z-50 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
'z-50 w-56 origin-top-right overflow-auto rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
className
)}
{...props}
+46 -8
View File
@@ -1,5 +1,7 @@
import * as React from 'react';
import {createPortal} from 'react-dom';
import {cn} from '@/lib/utils';
import {placeUnder} from '@/lib/popup-position';
import {ChevronDown, Check} from 'lucide-react';
export interface SelectOption {
@@ -32,11 +34,16 @@ const useSelectContext = () => {
};
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, _ref) => {
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, ref) => {
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState(value);
const containerRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const popupRef = React.useRef<HTMLDivElement>(null);
const [popupStyle, setPopupStyle] = React.useState<React.CSSProperties>({});
// The forwarded ref points at the trigger, as it does on DropdownMenuTrigger.
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const displayValue = React.useMemo(() => {
const currentValue = value ?? internalValue;
@@ -62,11 +69,40 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
setInternalValue(value);
}, [value]);
// The popup is portalled to the body so an ancestor with overflow-hidden
// (a dialog card, a scroll container) cannot clip it.
React.useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = buttonRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
setPopupStyle({
position: 'fixed',
left: rect.left,
width: rect.width,
backgroundColor: 'var(--popover)',
...placeUnder(rect, window.innerHeight),
});
};
updatePosition();
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [open]);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
const target = event.target as Node;
if (containerRef.current?.contains(target) || popupRef.current?.contains(target)) return;
setOpen(false);
};
if (open) {
@@ -106,13 +142,15 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', open && 'transform rotate-180')} />
</button>
{open && (
{open && createPortal(
<div
className="absolute z-50 w-full mt-1 text-popover-foreground rounded-md border border-border shadow-lg max-h-60 overflow-auto"
style={{ backgroundColor: 'var(--popover)' }}
ref={popupRef}
className="z-50 text-popover-foreground rounded-md border border-border shadow-lg overflow-auto"
style={popupStyle}
>
{children}
</div>
</div>,
document.body,
)}
</div>
</SelectContext.Provider>
+2 -2
View File
@@ -142,8 +142,8 @@ export function useDeleteMultipleObjects() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucket, keys, prefix }: { bucket: string; keys: string[]; prefix?: string }) =>
objectsApi.deleteMultiple(bucket, keys, prefix),
mutationFn: ({ bucket, keys, prefixes }: { bucket: string; keys: string[]; prefixes?: string[] }) =>
objectsApi.deleteMultiple(bucket, keys, prefixes),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
+16 -6
View File
@@ -216,14 +216,24 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
}
}, [bucketName, currentContinuationToken, fetchObjects]);
const deleteMultipleObjects = useCallback(async (keys: string[]) => {
if (!bucketName || keys.length === 0) return false;
// Deletes the selected object keys and recursively deletes every object under
// each selected folder prefix.
const deleteMultipleObjects = useCallback(async (keys: string[], prefixes: string[] = []) => {
if (!bucketName || (keys.length === 0 && prefixes.length === 0)) return false;
try {
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
const keySet = new Set(keys);
setObjects(prev => prev.filter(obj =>
!keySet.has(obj.key) && !prefixes.some(prefix => obj.key.startsWith(prefix))
));
await objectsApi.deleteMultiple(bucketName, keys, prefixes);
const fileLabel = keys.length > 0 ? `${keys.length} file${keys.length > 1 ? 's' : ''}` : '';
const folderLabel = prefixes.length > 0 ? `${prefixes.length} folder${prefixes.length > 1 ? 's' : ''}` : '';
const summary = [fileLabel, folderLabel].filter(Boolean).join(' and ');
toast.success(`Successfully deleted ${summary}`);
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
toast.success(`Successfully deleted ${keys.length} file${keys.length > 1 ? 's' : ''}`);
await fetchObjects(currentContinuationToken, true);
return true;
} catch (error) {
@@ -231,7 +241,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
await fetchObjects(currentContinuationToken, true);
return false;
}
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
}, [bucketName, currentContinuationToken, fetchObjects]);
const createDirectory = useCallback(async (dirName: string) => {
if (!bucketName) return false;
@@ -0,0 +1,160 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { objectsApi } from '@/lib/api';
import { useObjectPreview } from './useObjectPreview';
vi.mock('@/lib/api', () => ({
objectsApi: {
get: vi.fn(),
getPreviewUrl: vi.fn(),
},
}));
const mockedGet = vi.mocked(objectsApi.get);
const mockedGetPreviewUrl = vi.mocked(objectsApi.getPreviewUrl);
beforeAll(() => {
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
// One client per test, created outside the component so rerenders reuse it.
function createWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe('useObjectPreview', () => {
it('fetches a blob and produces an object url for images', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])]));
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.kind).toBe('image');
expect(result.current.objectUrl).toBe('blob:mock-url');
expect(mockedGet).toHaveBeenCalledWith('b', 'pic.png');
});
it('decodes text content', async () => {
mockedGet.mockResolvedValue(new Blob(['{"a": 1}']));
const { result } = renderHook(() => useObjectPreview('b', 'data.json', 8, 'application/json'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.text).toBe('{"a": 1}');
});
it('reports binary content pretending to be text', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([0, 1, 2, 3, 4, 0, 1, 2, 3, 4])]));
const { result } = renderHook(() => useObjectPreview('b', 'weird.log', 10, undefined), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('binary'));
});
it('does not fetch when the object is over the limit', () => {
const { result } = renderHook(
() => useObjectPreview('b', 'big.txt', 6 * 1024 * 1024, 'text/plain'),
{ wrapper: createWrapper() },
);
expect(result.current.status).toBe('too-large');
expect(mockedGet).not.toHaveBeenCalled();
});
it('reports unsupported kinds without fetching', () => {
const { result } = renderHook(() => useObjectPreview('b', 'blob.bin', 10, undefined), { wrapper: createWrapper() });
expect(result.current.status).toBe('unsupported');
expect(mockedGet).not.toHaveBeenCalled();
});
it('mints a media url for video without fetching bytes', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/api/v1/buckets/b/objects/v.mp4?pt=tok', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10_000_000_000, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.mediaUrl).toBe('/api/v1/buckets/b/objects/v.mp4?pt=tok');
expect(mockedGet).not.toHaveBeenCalled();
});
it('re-mints once on media error, then reports error', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
expect(result.current.status).toBe('ready');
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('error'));
});
it('reports fetch failures and recovers on retry', async () => {
mockedGet.mockRejectedValueOnce(new Error('network down'));
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('error'));
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
});
it('revokes the object url on unmount', async () => {
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
const { result, unmount } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
unmount();
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('recovers when the target object changes after a media error', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/a?pt=1', expiresAt: 'later' });
const { result, rerender } = renderHook(
({ k }) => useObjectPreview('b', k, 10, 'video/mp4'),
{ wrapper: createWrapper(), initialProps: { k: 'a.mp4' } },
);
await waitFor(() => expect(result.current.status).toBe('ready'));
// Exhaust the single re-mint on object A, driving it into the error state.
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.onMediaError());
await waitFor(() => expect(result.current.status).toBe('error'));
// Switch to object B on the same hook instance. The stale error from A
// must not leave B stuck: it should load and reach ready.
mockedGetPreviewUrl.mockResolvedValue({ url: '/b?pt=1', expiresAt: 'later' });
rerender({ k: 'b.mp4' });
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.mediaUrl).toBe('/b?pt=1');
});
it('refetches the media url on retry', async () => {
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe('ready'));
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
act(() => result.current.retry());
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
expect(result.current.status).toBe('ready');
});
it('ignores a pending text decode after unmount', async () => {
let resolveText: (value: string) => void = () => {};
const blob = new Blob(['hello']);
vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
mockedGet.mockResolvedValue(blob);
const { result, unmount } = renderHook(() => useObjectPreview('b', 'a.txt', 5, 'text/plain'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.objectUrl).toBe('blob:mock-url'));
// Unmount before the decode resolves, then resolve it. The cancelled
// guard must swallow the late result rather than set state.
unmount();
act(() => resolveText('hello'));
expect(result.current.text).toBeNull();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { objectsApi } from '@/lib/api';
import {
getPreviewKind,
getPreviewMime,
getPreviewSizeLimit,
looksBinary,
type PreviewKind,
} from '@/lib/preview-utils';
export interface ObjectPreviewState {
kind: PreviewKind;
status: 'loading' | 'ready' | 'too-large' | 'unsupported' | 'binary' | 'error';
objectUrl: string | null;
text: string | null;
mediaUrl: string | null;
retry: () => void;
onMediaError: () => void;
}
// Documents (image, pdf, text) are fetched as blobs through the normal
// authenticated API path. Media (video, audio) gets a short-lived tokenized
// URL instead, because media elements cannot send an Authorization header
// and must stream with Range requests rather than load fully.
export function useObjectPreview(
bucket: string,
objectKey: string,
size: number,
contentType?: string,
): ObjectPreviewState {
const kind = getPreviewKind(contentType, objectKey);
const sizeLimit = getPreviewSizeLimit(kind);
const isDocument = kind === 'image' || kind === 'pdf' || kind === 'text';
const isMedia = kind === 'video' || kind === 'audio';
const tooLarge = isDocument && sizeLimit !== null && size > sizeLimit;
const blobQuery = useQuery({
queryKey: ['object-preview', bucket, objectKey],
queryFn: () => objectsApi.get(bucket, objectKey),
enabled: isDocument && !tooLarge,
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const urlQuery = useQuery({
queryKey: ['object-preview-url', bucket, objectKey],
queryFn: () => objectsApi.getPreviewUrl(bucket, objectKey),
enabled: isMedia,
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [text, setText] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const [mediaFailed, setMediaFailed] = useState(false);
const remintedRef = useRef(false);
// Reset media error state when the target object changes, so a failure on
// one object does not leave a later object stuck in the error state. The
// hook instance is reused across navigation, it does not remount.
useEffect(() => {
setMediaFailed(false);
remintedRef.current = false;
}, [bucket, objectKey]);
useEffect(() => {
const blob = blobQuery.data;
if (!blob) return;
let cancelled = false;
const typed = new Blob([blob], { type: getPreviewMime(kind, contentType, objectKey) });
const url = URL.createObjectURL(typed);
setObjectUrl(url);
if (kind === 'text') {
blob.text().then((decoded) => {
if (cancelled) return;
if (looksBinary(decoded.slice(0, 4096))) setIsBinary(true);
else setText(decoded);
});
}
return () => {
cancelled = true;
URL.revokeObjectURL(url);
setObjectUrl(null);
setText(null);
setIsBinary(false);
};
}, [blobQuery.data, kind, contentType, objectKey]);
const onMediaError = () => {
if (remintedRef.current) {
setMediaFailed(true);
return;
}
remintedRef.current = true;
urlQuery.refetch();
};
const retry = () => {
remintedRef.current = false;
setMediaFailed(false);
if (isDocument) blobQuery.refetch();
if (isMedia) urlQuery.refetch();
};
let status: ObjectPreviewState['status'];
if (kind === 'none') status = 'unsupported';
else if (tooLarge) status = 'too-large';
else if (isBinary) status = 'binary';
else if (mediaFailed || blobQuery.isError || urlQuery.isError) status = 'error';
else if (isMedia) status = urlQuery.data ? 'ready' : 'loading';
else if (kind === 'text') status = text !== null ? 'ready' : 'loading';
else status = objectUrl ? 'ready' : 'loading';
return {
kind,
status,
objectUrl,
text,
mediaUrl: urlQuery.data?.url ?? null,
retry,
onMediaError,
};
}
+74
View File
@@ -161,3 +161,77 @@
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
}
}
/* 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;
}
+10 -2
View File
@@ -382,8 +382,10 @@ export const objectsApi = {
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
},
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
const payload = { keys, ...(prefix && { prefix }) };
// Deletes the given object keys and/or recursively deletes every object under
// each folder prefix in a single request.
deleteMultiple: async (bucket: string, keys: string[], prefixes: string[] = []): Promise<void> => {
const payload = { keys, ...(prefixes.length > 0 && { prefixes }) };
await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload);
},
@@ -393,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,73 @@
import type { AccessKey } from '@/types';
export interface KeyPermissions {
read: boolean;
write: boolean;
owner: boolean;
}
export interface NewKeyRequest {
name: string;
permissions: KeyPermissions;
}
export interface CreateBucketResult {
bucket: 'ok' | 'failed';
key?: { name: string; accessKeyId: string; secretKey: string };
keyError?: string;
grantError?: string;
}
export interface CreateBucketDeps {
createBucket: (name: string) => Promise<void>;
createKey: (name: string) => Promise<AccessKey>;
grant: (bucket: string, accessKeyId: string, permissions: KeyPermissions) => Promise<void>;
}
const message = (e: unknown): string => (e instanceof Error ? e.message : String(e));
/**
* Bucket, then key, then grant, against three separate endpoints. There is no
* rollback: whatever succeeded stays and the caller renders the partial
* outcome. A failed grant still reports the key because the API returns the
* secret exactly once.
*/
export async function createBucketWithKey(
deps: CreateBucketDeps,
bucketName: string,
key?: NewKeyRequest,
): Promise<CreateBucketResult> {
try {
await deps.createBucket(bucketName);
} catch {
return { bucket: 'failed' };
}
if (!key) {
return { bucket: 'ok' };
}
let created: AccessKey;
try {
created = await deps.createKey(key.name);
} catch (e) {
return { bucket: 'ok', keyError: message(e) };
}
const result: CreateBucketResult = {
bucket: 'ok',
key: {
name: created.name || key.name,
accessKeyId: created.accessKeyId,
secretKey: created.secretKey ?? '',
},
};
try {
await deps.grant(bucketName, created.accessKeyId, key.permissions);
} catch (e) {
result.grantError = message(e);
}
return result;
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { highlight } from './highlight';
describe('highlight', () => {
it('highlights a known language', () => {
const html = highlight('{"a": 1}', 'json');
expect(html).toContain('hljs-');
});
it('escapes html in the source', () => {
const html = highlight('<script>alert(1)</script>', 'xml');
expect(html).not.toContain('<script>');
});
it('falls back to auto detection for null language', () => {
const html = highlight('SELECT * FROM t;', null);
expect(typeof html).toBe('string');
expect(html.length).toBeGreaterThan(0);
});
it('falls back to auto detection for an unregistered language', () => {
const html = highlight('plain words', 'klingon');
expect(typeof html).toBe('string');
});
});
+39
View File
@@ -0,0 +1,39 @@
// Loaded only via dynamic import() from the text preview renderer, so
// highlight.js never enters the main bundle.
import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
import css from 'highlight.js/lib/languages/css';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
import go from 'highlight.js/lib/languages/go';
import ini from 'highlight.js/lib/languages/ini';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import markdown from 'highlight.js/lib/languages/markdown';
import python from 'highlight.js/lib/languages/python';
import sql from 'highlight.js/lib/languages/sql';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('css', css);
hljs.registerLanguage('dockerfile', dockerfile);
hljs.registerLanguage('go', go);
hljs.registerLanguage('ini', ini);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('json', json);
hljs.registerLanguage('markdown', markdown);
hljs.registerLanguage('python', python);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('yaml', yaml);
// Returns highlighted HTML for the source text. hljs escapes the input, so
// the output is safe to assign as innerHTML.
export function highlight(text: string, language: string | null): string {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(text, { language }).value;
}
return hljs.highlightAuto(text).value;
}
+31
View File
@@ -0,0 +1,31 @@
export interface Placement {
/** Distance from the viewport top, when the popup opens downwards. */
top?: number;
/** Distance from the viewport bottom, when the popup flips above its trigger. */
bottom?: number;
maxHeight: number;
}
/**
* Places a floating layer against its trigger in viewport coordinates, for use
* with `position: fixed` so no ancestor's overflow can clip it. Flips above the
* trigger when the room below is too tight to be usable.
*/
export function placeUnder(
rect: { top: number; bottom: number },
viewportHeight: number,
gap = 4,
maxHeight = 240,
): Placement {
const spaceBelow = viewportHeight - rect.bottom - gap * 2;
const spaceAbove = rect.top - gap * 2;
const flip = spaceBelow < Math.min(maxHeight, 160) && spaceAbove > spaceBelow;
// Floor the height so a trigger at the very edge still shows a scrollable
// popup rather than collapsing to nothing.
const fit = (space: number) => Math.max(120, Math.min(maxHeight, space));
return flip
? { bottom: viewportHeight - rect.top + gap, maxHeight: fit(spaceAbove) }
: { top: rect.bottom + gap, maxHeight: fit(spaceBelow) };
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
getHighlightLanguage,
getPreviewKind,
getPreviewMime,
getPreviewSizeLimit,
looksBinary,
IMAGE_PREVIEW_MAX_BYTES,
PDF_PREVIEW_MAX_BYTES,
TEXT_PREVIEW_MAX_BYTES,
} from './preview-utils';
describe('getPreviewKind', () => {
it('trusts a specific content type first', () => {
expect(getPreviewKind('image/png', 'noext')).toBe('image');
expect(getPreviewKind('video/mp4', 'noext')).toBe('video');
expect(getPreviewKind('audio/mpeg', 'noext')).toBe('audio');
expect(getPreviewKind('application/pdf', 'noext')).toBe('pdf');
expect(getPreviewKind('text/plain', 'noext')).toBe('text');
expect(getPreviewKind('application/json', 'noext')).toBe('text');
});
it('ignores content type parameters', () => {
expect(getPreviewKind('text/plain; charset=utf-8', 'noext')).toBe('text');
});
it('falls back to the extension when the type is octet-stream', () => {
expect(getPreviewKind('application/octet-stream', 'photo.JPG')).toBe('image');
expect(getPreviewKind('application/octet-stream', 'clip.mp4')).toBe('video');
expect(getPreviewKind('application/octet-stream', 'song.flac')).toBe('audio');
expect(getPreviewKind('application/octet-stream', 'doc.pdf')).toBe('pdf');
expect(getPreviewKind('application/octet-stream', 'conf.yaml')).toBe('text');
expect(getPreviewKind(undefined, 'a/b/c.json')).toBe('text');
});
it('detects svg as image even though the backend rewrites its type', () => {
expect(getPreviewKind('application/octet-stream', 'logo.svg')).toBe('image');
});
it('detects Dockerfile without an extension', () => {
expect(getPreviewKind(undefined, 'build/Dockerfile')).toBe('text');
});
it('returns none for unknown files', () => {
expect(getPreviewKind('application/octet-stream', 'blob.bin')).toBe('none');
expect(getPreviewKind(undefined, 'noext')).toBe('none');
expect(getPreviewKind(undefined, 'archive.zip')).toBe('none');
});
it('does not treat a dotfile name as an extension', () => {
expect(getPreviewKind(undefined, '.gitignore')).toBe('none');
});
});
describe('getPreviewSizeLimit', () => {
it('caps documents per kind and leaves media unlimited', () => {
expect(getPreviewSizeLimit('text')).toBe(TEXT_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('image')).toBe(IMAGE_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('pdf')).toBe(PDF_PREVIEW_MAX_BYTES);
expect(getPreviewSizeLimit('video')).toBeNull();
expect(getPreviewSizeLimit('audio')).toBeNull();
expect(getPreviewSizeLimit('none')).toBe(0);
});
});
describe('getPreviewMime', () => {
it('restores the mime type the backend rewrote', () => {
expect(getPreviewMime('image', 'application/octet-stream', 'logo.svg')).toBe('image/svg+xml');
expect(getPreviewMime('pdf', 'application/octet-stream', 'doc.pdf')).toBe('application/pdf');
});
it('keeps a usable content type when there is no mapping', () => {
expect(getPreviewMime('text', 'text/plain', 'readme.txt')).toBe('text/plain');
expect(getPreviewMime('text', undefined, 'readme.weird')).toBe('application/octet-stream');
});
});
describe('getHighlightLanguage', () => {
it('maps common extensions', () => {
expect(getHighlightLanguage('a.json')).toBe('json');
expect(getHighlightLanguage('a.yml')).toBe('yaml');
expect(getHighlightLanguage('a.tsx')).toBe('typescript');
expect(getHighlightLanguage('Dockerfile')).toBe('dockerfile');
});
it('returns null for unmapped extensions', () => {
expect(getHighlightLanguage('a.log')).toBeNull();
});
});
describe('looksBinary', () => {
it('accepts ordinary text with newlines and tabs', () => {
expect(looksBinary('hello\n\tworld\r\n')).toBe(false);
expect(looksBinary('')).toBe(false);
});
it('flags replacement-heavy content', () => {
expect(looksBinary('ab')).toBe(true);
});
it('flags control-character-heavy content', () => {
expect(looksBinary('\x00\x01\x02abc')).toBe(true);
});
it('tolerates a small fraction of oddities', () => {
expect(looksBinary('a'.repeat(99) + '\x00')).toBe(false);
});
});
+126
View File
@@ -0,0 +1,126 @@
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'none';
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
export const TEXT_HIGHLIGHT_MAX_BYTES = 1 * 1024 * 1024;
export const IMAGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
export const PDF_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
const imageMimeByExtension: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
svg: 'image/svg+xml',
};
const videoExtensions = new Set(['mp4', 'webm', 'ogv', 'mov']);
const audioExtensions = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
const textExtensions = new Set([
'json', 'yaml', 'yml', 'ini', 'toml', 'xml', 'csv', 'md', 'log', 'txt',
'conf', 'cfg', 'env', 'sh', 'bash', 'py', 'js', 'ts', 'jsx', 'tsx',
'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'sql', 'html', 'css', 'dockerfile',
]);
function getExtension(key: string): string {
const name = key.split('/').pop() ?? '';
if (name.toLowerCase() === 'dockerfile') return 'dockerfile';
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
// Content-Type first, extension fallback. The fallback matters: Garage often
// stores application/octet-stream, and the backend rewrites unsafe types to
// it, so a generic type defers to the file extension.
export function getPreviewKind(contentType: string | undefined, key: string): PreviewKind {
const ct = (contentType ?? '').split(';')[0].trim().toLowerCase();
if (ct && ct !== 'application/octet-stream') {
if (ct.startsWith('image/')) return 'image';
if (ct.startsWith('video/')) return 'video';
if (ct.startsWith('audio/')) return 'audio';
if (ct === 'application/pdf') return 'pdf';
if (ct.startsWith('text/') || ct === 'application/json') return 'text';
}
const ext = getExtension(key);
if (ext in imageMimeByExtension) return 'image';
if (videoExtensions.has(ext)) return 'video';
if (audioExtensions.has(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
if (textExtensions.has(ext)) return 'text';
return 'none';
}
// Byte limit per kind. null means no limit because media streams with Range
// requests and is never fully loaded into memory.
export function getPreviewSizeLimit(kind: PreviewKind): number | null {
switch (kind) {
case 'text':
return TEXT_PREVIEW_MAX_BYTES;
case 'image':
return IMAGE_PREVIEW_MAX_BYTES;
case 'pdf':
return PDF_PREVIEW_MAX_BYTES;
case 'video':
case 'audio':
return null;
case 'none':
return 0;
}
}
// The backend rewrites unsafe Content-Types to application/octet-stream, so
// blob URLs need their MIME type restored for the SVG <img> case and the
// browser PDF viewer.
export function getPreviewMime(kind: PreviewKind, contentType: string | undefined, key: string): string {
if (kind === 'image') {
return imageMimeByExtension[getExtension(key)] ?? contentType ?? 'application/octet-stream';
}
if (kind === 'pdf') return 'application/pdf';
return contentType || 'application/octet-stream';
}
const languageByExtension: Record<string, string> = {
json: 'json',
yaml: 'yaml',
yml: 'yaml',
ini: 'ini',
toml: 'ini',
xml: 'xml',
md: 'markdown',
sh: 'bash',
bash: 'bash',
py: 'python',
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
go: 'go',
sql: 'sql',
html: 'xml',
css: 'css',
dockerfile: 'dockerfile',
};
export function getHighlightLanguage(key: string): string | null {
return languageByExtension[getExtension(key)] ?? null;
}
// A text preview only makes sense for content that decodes as text. More
// than 10 percent replacement or non-whitespace control characters in the
// sample means the file is binary despite its name.
export function looksBinary(sample: string): boolean {
if (sample.length === 0) return false;
let suspicious = 0;
let total = 0;
for (const ch of sample) {
total++;
const code = ch.codePointAt(0) ?? 0;
if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
suspicious++;
}
}
return suspicious / total > 0.1;
}
+2 -74
View File
@@ -15,6 +15,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { CredentialField } from '@/components/ui/credential-field';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
DropdownMenu,
@@ -32,82 +33,9 @@ import {accessApi, bucketsApi} from '@/lib/api';
import {queryKeys} from '@/lib/query-client';
import {formatDate} from '@/lib/utils';
import type {AccessKey, Bucket, BucketPermission} from '@/types';
import {AlertTriangle, Calendar, Check, Copy, Database, Edit, Eye, EyeOff, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
import {AlertTriangle, Calendar, Copy, Database, Edit, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
import {toast} from 'sonner';
function CredentialField({
label,
value,
mono = true,
breakAll = false,
maskable = false,
loading = false,
}: {
label: string;
value: string;
mono?: boolean;
breakAll?: boolean;
maskable?: boolean;
loading?: boolean;
}) {
const [copied, setCopied] = useState(false);
const [revealed, setRevealed] = useState(!maskable);
const copy = () => {
if (!value) return;
navigator.clipboard.writeText(value);
setCopied(true);
toast.success(`${label} copied`);
setTimeout(() => setCopied(false), 1600);
};
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
{label}
</label>
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={copy}
disabled={loading || !value}
title="Click to copy"
className={cn(
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
mono && 'font-mono',
breakAll ? 'break-all' : 'truncate',
)}
>
{loading ? (
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</span>
) : (
display
)}
</button>
{maskable && (
<Button
variant="secondary"
size="icon"
onClick={() => setRevealed((r) => !r)}
aria-label={revealed ? 'Hide' : 'Reveal'}
disabled={loading || !value}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
}
export function AccessControl() {
const queryClient = useQueryClient();
const [keys, setKeys] = useState<AccessKey[]>([]);
+34 -6
View File
@@ -1,8 +1,12 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
import { usePermissions } from '@/hooks/usePermissions';
import { accessApi, bucketsApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-client';
import { createBucketWithKey, type NewKeyRequest } from '@/lib/create-bucket-with-key';
import { BucketListView } from '@/components/buckets/BucketListView';
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
@@ -17,18 +21,41 @@ export function Buckets() {
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
const [deleting, setDeleting] = useState(false);
const queryClient = useQueryClient();
const { hasAnyPerm } = usePermissions();
const { data: buckets = [], isLoading } = useBuckets();
const createMutation = useCreateBucket();
const deleteMutation = useDeleteBucket();
const createBucket = async (name: string, region?: string) => {
try {
await createMutation.mutateAsync({ name, region });
return true;
} catch {
return false;
// Both grant permissions are required by POST /buckets/:name/permissions, and
// the bucket does not exist yet, so this is a best-effort check across the
// subject's bindings. A 403 at call time surfaces in the dialog's outcome panel.
const canCreateKey =
hasAnyPerm('key.create') &&
hasAnyPerm('permission.allow_bucket_key') &&
hasAnyPerm('permission.deny_bucket_key');
const createBucket = async (name: string, key?: NewKeyRequest) => {
const result = await createBucketWithKey(
{
createBucket: (n) => createMutation.mutateAsync({ name: n }),
// Called directly, not through useCreateAccessKey and
// useGrantBucketPermission: their success toasts would stack on top of
// the dialog's own outcome panel.
createKey: (n) => accessApi.createKey(n),
grant: (bucket, accessKeyId, permissions) =>
bucketsApi.grantPermission(bucket, accessKeyId, permissions),
},
name,
key,
);
if (result.key) {
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(name) });
}
return result;
};
const confirmDelete = async () => {
@@ -74,6 +101,7 @@ export function Buckets() {
open={createOpen}
onOpenChange={setCreateOpen}
onCreateBucket={createBucket}
canCreateKey={canCreateKey}
/>
<DangerousConfirmDialog
+20 -14
View File
@@ -1,4 +1,5 @@
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
import { Link } from 'react-router-dom';
import { PageHeader } from '@/components/ui/page-header';
import { IconTile } from '@/components/ui/icon-tile';
import { EmptyState } from '@/components/ui/empty-state';
@@ -154,20 +155,25 @@ export function Dashboard() {
) : (
<ul className="divide-y divide-[var(--border)]">
{buckets.slice(0, 5).map((bucket) => (
<li key={bucket.name} className="flex items-center gap-3 py-3">
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
Created {new Date(bucket.creationDate).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
{bucket.size ? formatBytes(bucket.size) : '—'}
</p>
</div>
<li key={bucket.name}>
<Link
to={`/buckets/${bucket.name}/objects`}
className="-mx-2 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors hover:bg-[var(--muted)]"
>
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
Created {new Date(bucket.creationDate).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
{bucket.size ? formatBytes(bucket.size) : '—'}
</p>
</div>
</Link>
</li>
))}
</ul>
+10
View File
@@ -0,0 +1,10 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// Testing Library's automatic cleanup only self-registers when a global
// afterEach exists. This harness does not set test.globals, so register it
// explicitly to unmount rendered trees between tests.
afterEach(() => {
cleanup();
});
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
include: [
'src/lib/preview-utils.ts',
'src/lib/highlight.ts',
'src/hooks/useObjectPreview.ts',
'src/components/buckets/ObjectPreview.tsx',
],
thresholds: { statements: 90, branches: 90, functions: 90, lines: 90 },
},
},
});
+2 -2
View File
@@ -3,8 +3,8 @@ name: garage-ui
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
icon: https://helm.noste.dev/garage.png
type: application
version: 0.9.0 # x-release-please-version
appVersion: v0.9.0 # x-release-please-version
version: 0.10.0 # x-release-please-version
appVersion: v0.10.0 # x-release-please-version
keywords:
- garage
- s3
+7 -6
View File
@@ -2,8 +2,8 @@
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
[![Version](https://img.shields.io/badge/version-0.9.0?color=blue)](Chart.yaml) <!-- x-release-please-version -->
[![App Version](https://img.shields.io/badge/app%20version-v0.9.0?color=green)](Chart.yaml) <!-- x-release-please-version -->
[![Version](https://img.shields.io/badge/version-0.10.0?color=blue)](Chart.yaml) <!-- x-release-please-version -->
[![App Version](https://img.shields.io/badge/app%20version-v0.10.0?color=green)](Chart.yaml) <!-- x-release-please-version -->
## Table of Contents
@@ -689,16 +689,17 @@ Enable Prometheus metrics scraping (requires Prometheus Operator):
serviceMonitor:
enabled: true
interval: 30s
path: /api/v1/monitoring/metrics
# /metrics is served only when config.auth.metrics_public is true
path: /metrics
labels:
prometheus: kube-prometheus
```
### Metrics Endpoint
The application exposes metrics at:
- Path: `/api/v1/monitoring/metrics`
- Format: Prometheus format (proxies Garage Admin API metrics)
The application exposes Prometheus-format metrics (proxying the Garage Admin API) at:
- `/api/v1/monitoring/metrics`: always registered, requires authentication.
- `/metrics`: top-level, unauthenticated. Served ONLY when `config.auth.metrics_public` is `true`. Use this for Prometheus scraping when authentication is enabled, and restrict access with a NetworkPolicy / trusted scrape network.
### Health Checks
+6 -1
View File
@@ -199,6 +199,11 @@
}
}
},
"metrics_public": {
"type": "boolean",
"description": "Expose Prometheus metrics at top-level /metrics without authentication (required for scraping when auth is enabled). Restrict access with a NetworkPolicy.",
"default": false
},
"admin": {
"type": "object",
"description": "Admin authentication settings (username/password)",
@@ -846,7 +851,7 @@
"path": {
"type": "string",
"description": "Metrics endpoint path",
"default": "/api/v1/monitoring/metrics"
"default": "/metrics"
},
"labels": {
"type": "object",
+11 -1
View File
@@ -74,6 +74,14 @@ config:
name: ""
key: "jwt-key.pem"
# Expose Prometheus metrics at top-level /metrics WITHOUT authentication.
# Required for Prometheus to scrape when auth (admin/token/oidc) is enabled,
# since scrapers do not send credentials. Pairs with serviceMonitor below.
# WARNING: exposes operational cluster telemetry (bucket counts, request
# rates, storage sizes) unauthenticated, with no object data or secrets. Restrict
# access with a NetworkPolicy / trusted scrape network.
metrics_public: false
# Admin authentication (username/password)
admin:
enabled: false
@@ -236,7 +244,9 @@ readinessProbe:
serviceMonitor:
enabled: false
interval: 30s
path: /api/v1/monitoring/metrics
# Scrape path. The default /metrics is served only when
# config.auth.metrics_public is true (required when authentication is enabled).
path: /metrics
labels: {}
# NetworkPolicy