fix(05): WR-05 gate public file serving on is_public
This commit is contained in:
@@ -2,8 +2,10 @@ package attach
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,6 +55,42 @@ func (f File) Public() bool {
|
|||||||
return *f.IsPublic
|
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
|
var all []any
|
||||||
|
|
||||||
// Register appends models so schema tooling can see File without a plugin registry.
|
// 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) {
|
func TestFileSelfRegisters(t *testing.T) {
|
||||||
for _, m := range All() {
|
for _, m := range All() {
|
||||||
switch m.(type) {
|
switch m.(type) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func assertFileRow(t *testing.T, gdb *gorm.DB, id uint, want bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var n int64
|
var n int64
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package attach
|
package attach
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultStaticContentType = "application/octet-stream"
|
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
|
// 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;
|
// original's partition. The blob key is the validated 4-segment path;
|
||||||
// unvalidated request segments never reach NewReader (T-05-13).
|
// 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 {
|
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, "/")
|
prefix = strings.TrimSuffix(prefix, "/")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
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)
|
reader, err := bucket.NewReader(r.Context(), key, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package attach
|
package attach
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -114,3 +115,60 @@ func TestStaticHandlerServesThumbURL(t *testing.T) {
|
|||||||
t.Fatalf("mismatched original partition status = %d, want 404", mismatch.Code)
|
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