diff --git a/lagoon/attach/static.go b/lagoon/attach/static.go index f644d15..114b6b9 100644 --- a/lagoon/attach/static.go +++ b/lagoon/attach/static.go @@ -10,9 +10,10 @@ import ( const defaultStaticContentType = "application/octet-stream" -// StaticHandler serves GET prefix// from bucket. -// The blob key is rebuilt from disk_name via PartitionDirectory; request -// path segments never reach NewReader unvalidated (T-05-13). +// StaticHandler serves GET prefix// from bucket. +// 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). func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler { prefix = strings.TrimSuffix(prefix, "/") 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) return } - diskName, ok := parsePublicBlobPath(rel) + key, ok := parsePublicBlobPath(rel) if !ok { http.NotFound(w, r) return } - key := BlobKey(diskName) reader, err := bucket.NewReader(r.Context(), key, nil) if err != nil { http.NotFound(w, r) @@ -66,10 +66,13 @@ func stripStaticPrefix(path, prefix string) (string, bool) { return "", false } -// parsePublicBlobPath accepts exactly 3 partition groups plus disk_name -// whose PartitionDirectory matches those groups. Rejects "..", empty -// segments, extra slashes, and mismatched partitions. -func parsePublicBlobPath(p string) (string, bool) { +// parsePublicBlobPath accepts exactly 3 partition groups plus a filename +// and returns that path as the blob key. Originals must live in +// PartitionDirectory(filename); thumb_* names are stored beside the +// 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, "//") { return "", false } @@ -82,11 +85,13 @@ func parsePublicBlobPath(p string) (string, bool) { return "", false } } - diskName := parts[3] + filename := parts[3] got := strings.Join(parts[:3], "/") - want := strings.TrimSuffix(PartitionDirectory(diskName), "/") - if got != want { - return "", false + if !strings.HasPrefix(filename, "thumb_") { + want := strings.TrimSuffix(PartitionDirectory(filename), "/") + if got != want { + return "", false + } } - return diskName, true + return got + "/" + filename, true } diff --git a/lagoon/attach/static_test.go b/lagoon/attach/static_test.go index 6c1c8e2..67aa567 100644 --- a/lagoon/attach/static_test.go +++ b/lagoon/attach/static_test.go @@ -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) + } +}