fix(05): WR-05 gate public file serving on is_public
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user