fix(05): CR-01 serve thumb URLs from the validated path key

This commit is contained in:
Jakub Zych
2026-09-19 15:30:18 +02:00
parent c0b2b6db08
commit 44126cc935
2 changed files with 64 additions and 14 deletions

View File

@@ -10,9 +10,10 @@ import (
const defaultStaticContentType = "application/octet-stream" const defaultStaticContentType = "application/octet-stream"
// StaticHandler serves GET prefix/<partition>/<disk_name> from bucket. // StaticHandler serves GET prefix/<partition>/<filename> from bucket.
// The blob key is rebuilt from disk_name via PartitionDirectory; request // filename is the original disk_name or a thumb_* sibling stored in the
// path segments never reach NewReader unvalidated (T-05-13). // original's partition. The blob key is the validated 4-segment path;
// unvalidated request segments never reach NewReader (T-05-13).
func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler { func StaticHandler(bucket *blob.Bucket, prefix string) 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) {
@@ -29,12 +30,11 @@ func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
diskName, ok := parsePublicBlobPath(rel) key, ok := parsePublicBlobPath(rel)
if !ok { if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
key := BlobKey(diskName)
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)
@@ -66,10 +66,13 @@ func stripStaticPrefix(path, prefix string) (string, bool) {
return "", false return "", false
} }
// parsePublicBlobPath accepts exactly 3 partition groups plus disk_name // parsePublicBlobPath accepts exactly 3 partition groups plus a filename
// whose PartitionDirectory matches those groups. Rejects "..", empty // and returns that path as the blob key. Originals must live in
// segments, extra slashes, and mismatched partitions. // PartitionDirectory(filename); thumb_* names are stored beside the
func parsePublicBlobPath(p string) (string, bool) { // original, so their partition is not derived from the thumb filename.
// Rejects "..", empty segments, extra slashes, and mismatched original
// partitions.
func parsePublicBlobPath(p string) (key string, ok bool) {
if p == "" || strings.Contains(p, "\\") || strings.Contains(p, "..") || strings.Contains(p, "//") { if p == "" || strings.Contains(p, "\\") || strings.Contains(p, "..") || strings.Contains(p, "//") {
return "", false return "", false
} }
@@ -82,11 +85,13 @@ func parsePublicBlobPath(p string) (string, bool) {
return "", false return "", false
} }
} }
diskName := parts[3] filename := parts[3]
got := strings.Join(parts[:3], "/") got := strings.Join(parts[:3], "/")
want := strings.TrimSuffix(PartitionDirectory(diskName), "/") if !strings.HasPrefix(filename, "thumb_") {
if got != want { want := strings.TrimSuffix(PartitionDirectory(filename), "/")
return "", false if got != want {
return "", false
}
} }
return diskName, true return got + "/" + filename, true
} }

View File

@@ -69,3 +69,48 @@ func TestStaticHandler(t *testing.T) {
} }
} }
} }
func TestStaticHandlerServesThumbURL(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
f := &File{ID: 42, DiskName: "abc123xyz.jpg"}
if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), testJPEG(t), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
thumbURL, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
t.Fatal(err)
}
const wantURL = "/storage/uploads/abc/123/xyz/thumb_42_200_200_0_0_crop.jpg"
if thumbURL != wantURL {
t.Fatalf("Thumb URL = %q, want %q", thumbURL, wantURL)
}
h := StaticHandler(bucket, "/storage/uploads")
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
res, err := http.Get(srv.URL + thumbURL)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("thumb GET status = %d, want 200", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if len(body) == 0 {
t.Fatal("thumb body is empty")
}
mismatch := httptest.NewRecorder()
h.ServeHTTP(mismatch, httptest.NewRequest(http.MethodGet, "/storage/uploads/foo/bar/baz/abc123xyz.jpg", nil))
if mismatch.Code != http.StatusNotFound {
t.Fatalf("mismatched original partition status = %d, want 404", mismatch.Code)
}
}