fix(05): WR-05 gate public file serving on is_public

This commit is contained in:
Jakub Zych
2026-09-19 15:37:04 +02:00
parent c12e6576f6
commit 56156ae7c3
5 changed files with 195 additions and 0 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}