diff --git a/lagoon/attach/file.go b/lagoon/attach/file.go index 43dd760..b5eab6f 100644 --- a/lagoon/attach/file.go +++ b/lagoon/attach/file.go @@ -2,8 +2,10 @@ package attach import ( "context" + "errors" "fmt" "io" + "strconv" "strings" "time" @@ -53,6 +55,42 @@ func (f File) Public() bool { return *f.IsPublic } +func fileIsPublic(ctx context.Context, db *gorm.DB, filename string) (bool, error) { + if db == nil || filename == "" { + return false, nil + } + var f File + q := db.WithContext(ctx).Select("is_public") + var err error + if id, ok := thumbFileID(filename); ok { + err = q.First(&f, id).Error + } else { + err = q.Where("disk_name = ?", filename).First(&f).Error + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return f.Public(), nil +} + +func thumbFileID(name string) (uint, bool) { + if !strings.HasPrefix(name, "thumb_") { + return 0, false + } + idStr, _, ok := strings.Cut(strings.TrimPrefix(name, "thumb_"), "_") + if !ok || idStr == "" { + return 0, false + } + n, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || n == 0 { + return 0, false + } + return uint(n), true +} + var all []any // Register appends models so schema tooling can see File without a plugin registry. diff --git a/lagoon/attach/file_test.go b/lagoon/attach/file_test.go index e3fe502..6295d0f 100644 --- a/lagoon/attach/file_test.go +++ b/lagoon/attach/file_test.go @@ -37,6 +37,19 @@ func TestFileIsPublicDefaultsTrue(t *testing.T) { } } +func TestThumbFileID(t *testing.T) { + id, ok := thumbFileID("thumb_42_200_200_0_0_crop.jpg") + if !ok || id != 42 { + t.Fatalf("thumb id = %d ok=%v, want 42 true", id, ok) + } + if _, ok := thumbFileID("abc123xyz.jpg"); ok { + t.Fatal("original disk_name must not parse as a thumb id") + } + if _, ok := thumbFileID("thumb_x_200_200_0_0_crop.jpg"); ok { + t.Fatal("non-numeric thumb id must be rejected") + } +} + func TestFileSelfRegisters(t *testing.T) { for _, m := range All() { switch m.(type) { diff --git a/lagoon/attach/lifecycle_test.go b/lagoon/attach/lifecycle_test.go index 8cbb5d9..bff9a9a 100644 --- a/lagoon/attach/lifecycle_test.go +++ b/lagoon/attach/lifecycle_test.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "fmt" + "net/http" + "net/http/httptest" "strconv" "testing" "time" @@ -185,6 +187,62 @@ func TestFileCreateDefaultsIsPublic(t *testing.T) { } } +func TestStaticHandlerPublicLooksUpIsPublic(t *testing.T) { + if testing.Short() { + t.Skip("requires testcontainers postgres") + } + ctx := t.Context() + gdb := attachGorm(t) + if err := lagoon.Migrate(gdb, nil); err != nil { + t.Fatal(err) + } + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + + pub := true + priv := false + publicFile := attach.File{DiskName: "abc123xyz.jpg", FileName: "cover.jpg", FileSize: 1, ContentType: "image/jpeg", IsPublic: &pub} + privateFile := attach.File{DiskName: "def456uvw.jpg", FileName: "secret.jpg", FileSize: 1, ContentType: "image/jpeg", IsPublic: &priv} + if err := gdb.Create(&publicFile).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Create(&privateFile).Error; err != nil { + t.Fatal(err) + } + for _, f := range []attach.File{publicFile, privateFile} { + if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), []byte(f.FileName), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { + t.Fatal(err) + } + thumbKey := attach.PartitionDirectory(f.DiskName) + attach.ThumbFilename(f.ID, 50, 50, 0, 0, "crop", "jpg") + if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { + t.Fatal(err) + } + } + + h := attach.StaticHandlerPublic(bucket, "/storage/uploads", gdb) + get := func(path string) int { + t.Helper() + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + return rr.Code + } + if code := get("/storage/uploads/abc/123/xyz/abc123xyz.jpg"); code != http.StatusOK { + t.Fatalf("public original status = %d, want 200", code) + } + if code := get("/storage/uploads/abc/123/xyz/" + attach.ThumbFilename(publicFile.ID, 50, 50, 0, 0, "crop", "jpg")); code != http.StatusOK { + t.Fatalf("public thumb status = %d, want 200", code) + } + if code := get("/storage/uploads/def/456/uvw/def456uvw.jpg"); code != http.StatusNotFound { + t.Fatalf("private original status = %d, want 404", code) + } + if code := get("/storage/uploads/def/456/uvw/" + attach.ThumbFilename(privateFile.ID, 50, 50, 0, 0, "crop", "jpg")); code != http.StatusNotFound { + t.Fatalf("private thumb status = %d, want 404", code) + } + if code := get("/storage/uploads/abc/123/xyz/missing.jpg"); code != http.StatusNotFound { + t.Fatalf("unknown disk_name status = %d, want 404", code) + } +} + func assertFileRow(t *testing.T, gdb *gorm.DB, id uint, want bool) { t.Helper() var n int64 diff --git a/lagoon/attach/static.go b/lagoon/attach/static.go index 114b6b9..5d38116 100644 --- a/lagoon/attach/static.go +++ b/lagoon/attach/static.go @@ -1,11 +1,14 @@ package attach import ( + "context" "io" "net/http" + "path" "strings" "gocloud.dev/blob" + "gorm.io/gorm" ) const defaultStaticContentType = "application/octet-stream" @@ -14,7 +17,25 @@ const defaultStaticContentType = "application/octet-stream" // filename is the original disk_name or a thumb_* sibling stored in the // original's partition. The blob key is the validated 4-segment path; // unvalidated request segments never reach NewReader (T-05-13). +// +// This is the public-disk handler: it does not consult system_files.is_public. +// Protected files must not be stored in this bucket (Winter uses a second +// disk). To 404 is_public=false rows, mount StaticHandlerPublic instead. +// Do not mount the ungated handler on the app origin — same-origin +// Content-Type from uploads is XSS-relevant. func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler { + return servePublicBlobs(bucket, prefix, nil) +} + +// StaticHandlerPublic is StaticHandler plus an is_public gate. Missing +// rows and is_public=false both 404. The lookup runs before NewReader. +func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler { + return servePublicBlobs(bucket, prefix, func(ctx context.Context, filename string) (bool, error) { + return fileIsPublic(ctx, db, filename) + }) +} + +func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Context, string) (bool, error)) http.Handler { prefix = strings.TrimSuffix(prefix, "/") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { @@ -35,6 +56,13 @@ func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler { http.NotFound(w, r) return } + if allow != nil { + ok, err := allow(r.Context(), path.Base(key)) + if err != nil || !ok { + http.NotFound(w, r) + return + } + } reader, err := bucket.NewReader(r.Context(), key, nil) if err != nil { http.NotFound(w, r) diff --git a/lagoon/attach/static_test.go b/lagoon/attach/static_test.go index 67aa567..9d3afcc 100644 --- a/lagoon/attach/static_test.go +++ b/lagoon/attach/static_test.go @@ -1,6 +1,7 @@ package attach import ( + "context" "io" "net/http" "net/http/httptest" @@ -114,3 +115,60 @@ func TestStaticHandlerServesThumbURL(t *testing.T) { t.Fatalf("mismatched original partition status = %d, want 404", mismatch.Code) } } + +func TestStaticHandlerPublicGate(t *testing.T) { + ctx := t.Context() + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + + diskName := "abc123xyz.jpg" + body := []byte("cover-bytes") + if err := bucket.WriteAll(ctx, BlobKey(diskName), body, &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { + t.Fatal(err) + } + thumbName := ThumbFilename(42, 200, 200, 0, 0, "crop", "jpg") + thumbKey := PartitionDirectory(diskName) + thumbName + if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb-bytes"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { + t.Fatal(err) + } + + var seen []string + deny := servePublicBlobs(bucket, "/storage/uploads", func(_ context.Context, name string) (bool, error) { + seen = append(seen, name) + return false, nil + }) + for _, path := range []string{ + "/storage/uploads/abc/123/xyz/abc123xyz.jpg", + "/storage/uploads/abc/123/xyz/" + thumbName, + } { + rr := httptest.NewRecorder() + deny.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("private %s status = %d, want 404", path, rr.Code) + } + } + if len(seen) != 2 || seen[0] != diskName || seen[1] != thumbName { + t.Fatalf("allow saw %v, want [%s %s] before NewReader", seen, diskName, thumbName) + } + + allow := servePublicBlobs(bucket, "/storage/uploads", func(_ context.Context, name string) (bool, error) { + return name == diskName, nil + }) + ok := httptest.NewRecorder() + allow.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/abc123xyz.jpg", nil)) + if ok.Code != http.StatusOK { + t.Fatalf("public original status = %d, want 200", ok.Code) + } + blocked := httptest.NewRecorder() + allow.ServeHTTP(blocked, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/"+thumbName, nil)) + if blocked.Code != http.StatusNotFound { + t.Fatalf("denied thumb status = %d, want 404", blocked.Code) + } + + nilDB := StaticHandlerPublic(bucket, "/storage/uploads", nil) + missing := httptest.NewRecorder() + nilDB.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/abc123xyz.jpg", nil)) + if missing.Code != http.StatusNotFound { + t.Fatalf("nil db status = %d, want 404", missing.Code) + } +}