From 61828783a1c7260a36d6820bd394be2aa6110ea2 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Fri, 25 Sep 2026 15:40:21 +0200 Subject: [PATCH] refac: GET /static/uploadedImages/{filename} --- .../staticimages/staticImagesHandler.go | 98 +++++++ api/router.go | 2 + features/serve_uploaded_images.feature | 142 ++++++++++ features/serve_uploaded_images.qa.md | 82 ++++++ test/serve_uploaded_images_test.go | 268 ++++++++++++++++++ 5 files changed, 592 insertions(+) create mode 100644 api/handlers/staticimages/staticImagesHandler.go create mode 100644 features/serve_uploaded_images.feature create mode 100644 features/serve_uploaded_images.qa.md create mode 100644 test/serve_uploaded_images_test.go diff --git a/api/handlers/staticimages/staticImagesHandler.go b/api/handlers/staticimages/staticImagesHandler.go new file mode 100644 index 0000000..c26cbba --- /dev/null +++ b/api/handlers/staticimages/staticImagesHandler.go @@ -0,0 +1,98 @@ +package staticimages + +import ( + "io" + "io/fs" + "net/http" + "os" + "path" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const cacheControl = "public, max-age=31536000, immutable" +const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox" +const fallbackContentType = "application/octet-stream" + +var contentTypeExtensions = map[string]string{ + ".png": "image/png", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".svg": "image/svg+xml", +} + +type Handler struct { + uploadDir fs.FS + modTime time.Time +} + +func NewHandler(uploadDir string) *Handler { + return &Handler{ + uploadDir: os.DirFS(uploadDir), + modTime: time.Now(), + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + + cleaned := cleanPath(requestedPath(r)) + file, stat, ok := h.openFile(cleaned) + if !ok { + http.NotFound(w, r) + return + } + defer file.Close() + + content, ok := file.(io.ReadSeeker) + if !ok { + http.Error(w, "File not seekable", http.StatusInternalServerError) + return + } + + contentType := contentTypeFor(cleaned) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", cacheControl) + if contentType == "image/svg+xml" { + w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy) + } + + http.ServeContent(w, r, cleaned, stat.ModTime(), content) +} + +func requestedPath(r *http.Request) string { + if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { + return rctx.RoutePath + } + return r.URL.Path +} + +func cleanPath(p string) string { + return strings.TrimPrefix(path.Clean("/"+p), "/") +} + +func (h *Handler) openFile(p string) (fs.File, fs.FileInfo, bool) { + if p == "" || p == "." { + return nil, nil, false + } + file, err := h.uploadDir.Open(p) + if err != nil { + return nil, nil, false + } + stat, err := file.Stat() + if err != nil || stat.IsDir() { + file.Close() + return nil, nil, false + } + return file, stat, true +} + +func contentTypeFor(p string) string { + ext := path.Ext(p) + if contentType, ok := contentTypeExtensions[ext]; ok { + return contentType + } + return fallbackContentType +} diff --git a/api/router.go b/api/router.go index 7cad8a5..ff7d5a8 100644 --- a/api/router.go +++ b/api/router.go @@ -5,6 +5,7 @@ import ( "RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/image" + "RenewCMS/api/handlers/staticimages" "RenewCMS/api/middleware" "RenewCMS/internal/infrastructure/config" "encoding/json" @@ -139,6 +140,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux { apiRouter.Use(chimiddleware.GetHead) apiRouter.Get("/healthz", GetHealth) apiRouter.Mount("/v1", backend) + apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir)) apiRouter.Mount("/", frontend) return apiRouter diff --git a/features/serve_uploaded_images.feature b/features/serve_uploaded_images.feature new file mode 100644 index 0000000..0410176 --- /dev/null +++ b/features/serve_uploaded_images.feature @@ -0,0 +1,142 @@ +Feature: Serving uploaded images as static files + + As the public site embedding article images + I want GET and HEAD /static/uploadedImages/{filename} to serve a file + straight out of the configured upload directory, without authentication + So that an uploaded image loads for anonymous visitors, a script hidden in + an uploaded SVG can't execute against the CMS origin, and a missing, + deleted, directory or path-traversal request answers 404 instead of the + SPA's index.html + + Background: + Given this slice adds api/handlers/staticimages.Handler, constructed with + NewHandler(uploadDir string) over config.Settings.UploadDir, with no + use case in front of it, because serving an already-stored file by + name is plain filesystem access over a config value, not a domain rule + And this slice mounts apiRouter.Mount("/static/uploadedImages", handler) + in api/router.go strictly before apiRouter.Mount("/", frontend), and + outside any jwtauth.Verifier / jwtauth.Authenticator group + And the handler maps the file extension to a Content-Type using the + mirror of persistence's contentTypeExtensions table: ".png" to + "image/png", ".jpeg" to "image/jpeg", ".webp" to "image/webp", ".svg" + to "image/svg+xml", and any other extension to + "application/octet-stream" + And every response from this handler, success or 404, sets + "X-Content-Type-Options: nosniff" + And every successful response sets a long-lived, public Cache-Control + header, e.g. "public, max-age=31536000, immutable", because a stored + filename is a UUID and its bytes never change + And an "image/svg+xml" response additionally sets + "Content-Security-Policy: default-src 'none'; style-src + 'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG + cannot run with the CMS origin's privileges + And the handler resolves the requested filename against uploadDir using + a cleaned, non-traversing path, exactly as frontHandler.go already + does for the SPA's static assets, and answers 404 for anything that + isn't a regular file directly inside uploadDir + + Scenario Outline: Requesting an existing raster image returns 200 with the matching Content-Type + Given the upload directory contains a file named with some + non-empty content + When the client sends GET /static/uploadedImages/ with no jwt + cookie + Then the response status is 200 + And the response Content-Type is + And the response Cache-Control is "public, max-age=31536000, immutable" + And the response X-Content-Type-Options is "nosniff" + And the response has no Content-Security-Policy header + And the response body is that file's exact content + + Examples: + | filename | content_type | + | 4b9e2f1a-1111-4a11-9a11-111111111111.png | image/png | + | 4b9e2f1a-2222-4a11-9a11-222222222222.jpeg | image/jpeg | + | 4b9e2f1a-3333-4a11-9a11-333333333333.webp | image/webp | + + Scenario: Requesting an existing SVG file returns 200 with a restrictive CSP + Given the upload directory contains a file named + "4b9e2f1a-4444-4a11-9a11-444444444444.svg" whose content is + "" + When the client sends GET /static/uploadedImages/4b9e2f1a-4444-4a11-9a11-444444444444.svg + with no jwt cookie + Then the response status is 200 + And the response Content-Type is "image/svg+xml" + And the response Cache-Control is "public, max-age=31536000, immutable" + And the response X-Content-Type-Options is "nosniff" + And the response Content-Security-Policy is + "default-src 'none'; style-src 'unsafe-inline'; sandbox" + And the response body is the exact SVG content, unmodified + + Scenario: Requesting a file with an unrecognized extension falls back to a generic Content-Type + Given the upload directory contains a file named "notes.txt" with some + non-empty content + When the client sends GET /static/uploadedImages/notes.txt with no jwt + cookie + Then the response status is 200 + And the response Content-Type is "application/octet-stream" + And the response X-Content-Type-Options is "nosniff" + And the response has no Content-Security-Policy header + + Scenario: Requesting a filename that was never uploaded answers 404, not the SPA + Given the upload directory contains no file named "missing.png" + And the frontend catch-all route would otherwise serve index.html for + any unmatched path + When the client sends GET /static/uploadedImages/missing.png with no jwt + cookie + Then the response status is 404 + And the response body is not the SPA's index.html content + And the response X-Content-Type-Options is "nosniff" + + Scenario: Requesting a file that was deleted after being stored answers 404 + Given the upload directory contained a file named + "4b9e2f1a-5555-4a11-9a11-555555555555.png", which has since been + removed from disk + When the client sends GET /static/uploadedImages/4b9e2f1a-5555-4a11-9a11-555555555555.png + with no jwt cookie + Then the response status is 404 + + Scenario: Requesting a directory inside the upload directory answers 404 + Given the upload directory contains a subdirectory named "nested" + holding a file named "4b9e2f1a-6666-4a11-9a11-666666666666.png" + When the client sends GET /static/uploadedImages/nested with no jwt + cookie + Then the response status is 404 + And the response body is not a directory listing + + Scenario Outline: A path-traversal attempt never escapes the upload directory + Given the upload directory contains a file named + "4b9e2f1a-7777-4a11-9a11-777777777777.png" + And a file named "settings.go" exists outside the upload directory, in + the repository root + When the client sends GET /static/uploadedImages/ with + no jwt cookie + Then the response status is 404 + And no file content from outside the upload directory is ever returned + + Examples: + | traversal_path | + | ../settings.go | + | ..%2fsettings.go | + | ..%2f..%2f..%2fsettings.go | + | %2e%2e%2fsettings.go | + + Scenario: HEAD returns the same headers as GET with no body + Given the upload directory contains a file named + "4b9e2f1a-8888-4a11-9a11-888888888888.webp" with some non-empty + content + When the client sends HEAD /static/uploadedImages/4b9e2f1a-8888-4a11-9a11-888888888888.webp + with no jwt cookie + Then the response status is 200 + And the response Content-Type is "image/webp" + And the response Cache-Control is "public, max-age=31536000, immutable" + And the response X-Content-Type-Options is "nosniff" + And the response body is empty + + Scenario: No authentication is required to fetch an uploaded image + Given the upload directory contains a file named + "4b9e2f1a-9999-4a11-9a11-999999999999.png" with some non-empty + content + And no jwt cookie is sent + When the client sends GET /static/uploadedImages/4b9e2f1a-9999-4a11-9a11-999999999999.png + Then the response status is 200 + And the response is not a redirect to a login route diff --git a/features/serve_uploaded_images.qa.md b/features/serve_uploaded_images.qa.md new file mode 100644 index 0000000..79799b0 --- /dev/null +++ b/features/serve_uploaded_images.qa.md @@ -0,0 +1,82 @@ +# QA procedure: serve_uploaded_images + +Package: `test` (black-box, new file `test/serve_uploaded_images_test.go`). + +## Setup shared by every case +1. Call `validator.InitValidator()` once (harmless even though this handler + does no validation). +2. Create a fresh `uploadDir := t.TempDir()` per test/subtest. This handler + has no domain repository to fake — it serves real files from a real + directory on disk, which is standard for filesystem-serving code and is + not the "real SQLite file" / "live server" / "real SMTP" the project + convention rules out. +3. Build `staticimages.NewHandler(uploadDir)` (`api/handlers/staticimages`). +4. Mount it on a fresh `chi.NewRouter()` mirroring `api/router.go`'s shape + after this slice: + - `r.Mount("/static/uploadedImages", staticHandler)` — no + `jwtauth.Verifier` / `jwtauth.Authenticator` wrapping. + - `r.Mount("/", stubFrontend)` where `stubFrontend` is a trivial + `http.HandlerFunc` that writes a recognizable marker string, e.g. + `"SPA-INDEX-MARKER"`, with status 200 — standing in for the real SPA + fallback, just to prove routing order without needing the embedded + `dist/` build. +5. Helper to seed a file: `os.WriteFile(filepath.Join(uploadDir, name), + content, 0o644)`, creating parent directories with `os.MkdirAll` when a + subdirectory case needs one. + +## HTTP checks +6. Write `4b9e2f1a-1111-....png`, `.jpeg`, `.webp` files with distinct + non-empty byte content. For each, `httptest.NewRequest("GET", + "/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`; + `Content-Type` equals the expected value; `Cache-Control` equals + `"public, max-age=31536000, immutable"`; `X-Content-Type-Options` equals + `"nosniff"`; `Content-Security-Policy` header is absent; body bytes equal + what was written. +7. Write a `.svg` file whose content contains an inline `onload` handler + (content doesn't matter functionally, just needs to be non-empty and + distinguishable). GET it. Assert status `200`, `Content-Type` is + `"image/svg+xml"`, `Content-Security-Policy` equals `"default-src + 'none'; style-src 'unsafe-inline'; sandbox"`, `X-Content-Type-Options` is + `"nosniff"`, body equals the exact SVG bytes (not sanitized/altered). +8. Write `notes.txt`. GET it. Assert status `200`, `Content-Type` is + `"application/octet-stream"`, no `Content-Security-Policy` header. +9. Don't create `missing.png`. GET `/static/uploadedImages/missing.png`. + Assert status `404`; assert the body does **not** contain + `"SPA-INDEX-MARKER"` (proves the static mount, not the frontend + fallback, produced the 404 — i.e. mount ordering in the test router + matches the required ordering in `api/router.go`). +10. Write a file, then `os.Remove` it before the request. GET its old name. + Assert status `404`. +11. `os.MkdirAll(filepath.Join(uploadDir, "nested"), 0o755)` and write a + file inside it. GET `/static/uploadedImages/nested`. Assert status + `404` (a directory is never listed or served). +12. Write one real file inside `uploadDir`, and separately write a marker + file (e.g. `"sentinel-outside"`) to a location outside `uploadDir` (a + second `t.TempDir()`, or a path one level above `uploadDir`) so a + successful escape would be detectable. For each of the traversal paths + `"../"+sentinelName`, `"..%2f"+sentinelName` (raw in the request path, + since `httptest.NewRequest` takes a literal target string and Go's + `net/http` mux/handler must not decode-then-escape), and a + triple-`..%2f` variant: send `GET /static/uploadedImages/`. + Assert status `404` for every case, and assert the response body never + equals the sentinel file's content. +13. Write a `.webp` file. Send `httptest.NewRequest("HEAD", ...)`. Assert + status `200`, the same `Content-Type` / `Cache-Control` / + `X-Content-Type-Options` headers as the GET case, and an empty response + body (`rec.Body.Len() == 0`). +14. Repeat step 6's PNG case with explicitly no `Cookie` header set on the + request (already true in step 6, but assert this once more as its own + subtest/comment-free assertion block) and confirm status `200`, not a + 3xx redirect and not `401`. + +## Manual verification (not exercised by the Go test) +- Real end-to-end mount order: run `task dev` or a built `task build` + binary, confirm `GET /static/uploadedImages/` returns 404 from + the real router (with the real embedded `dist/`), not `index.html`, since + the Go test above stands in a stub for the frontend rather than the + embedded SPA. +- Browser-level CSP enforcement: open an uploaded SVG containing a script + directly in a browser and confirm the CSP header actually blocks + execution (header presence is asserted by the test; browser enforcement + isn't). +- No SMTP involvement. diff --git a/test/serve_uploaded_images_test.go b/test/serve_uploaded_images_test.go new file mode 100644 index 0000000..cccb299 --- /dev/null +++ b/test/serve_uploaded_images_test.go @@ -0,0 +1,268 @@ +package test + +import ( + "RenewCMS/api/handlers/staticimages" + validator "RenewCMS/api/pkg/validator" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" +) + +const spaIndexMarker = "SPA-INDEX-MARKER" + +func newServeUploadedImagesRouter(uploadDir string) *chi.Mux { + r := chi.NewRouter() + r.Use(chimiddleware.GetHead) + r.Mount("/static/uploadedImages", staticimages.NewHandler(uploadDir)) + r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(spaIndexMarker)) + })) + return r +} + +func writeUploadedImageFile(t *testing.T, dir, name string, content []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { + t.Fatalf("failed to create parent directories: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, name), content, 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } +} + +func TestServeUploadedImagesHttp(t *testing.T) { + validator.InitValidator() + + t.Run("Requesting an existing raster image returns 200 with the matching Content-Type", func(t *testing.T) { + examples := []struct { + filename string + contentType string + }{ + {"4b9e2f1a-1111-4a11-9a11-111111111111.png", "image/png"}, + {"4b9e2f1a-2222-4a11-9a11-222222222222.jpeg", "image/jpeg"}, + {"4b9e2f1a-3333-4a11-9a11-333333333333.webp", "image/webp"}, + } + for _, example := range examples { + uploadDir := t.TempDir() + content := []byte("content-of-" + example.filename) + writeUploadedImageFile(t, uploadDir, example.filename, content) + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+example.filename, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != example.contentType { + t.Fatalf("expected Content-Type %q, got %q", example.contentType, got) + } + if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { + t.Fatalf("unexpected Cache-Control: %q", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("unexpected X-Content-Type-Options: %q", got) + } + if got := rec.Header().Get("Content-Security-Policy"); got != "" { + t.Fatalf("expected no Content-Security-Policy header, got %q", got) + } + if rec.Body.String() != string(content) { + t.Fatalf("unexpected body: %q", rec.Body.String()) + } + } + }) + + t.Run("Requesting an existing SVG file returns 200 with a restrictive CSP", func(t *testing.T) { + uploadDir := t.TempDir() + filename := "4b9e2f1a-4444-4a11-9a11-444444444444.svg" + content := []byte(``) + writeUploadedImageFile(t, uploadDir, filename, content) + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+filename, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" { + t.Fatalf("expected Content-Type image/svg+xml, got %q", got) + } + if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { + t.Fatalf("unexpected Cache-Control: %q", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("unexpected X-Content-Type-Options: %q", got) + } + expectedCsp := "default-src 'none'; style-src 'unsafe-inline'; sandbox" + if got := rec.Header().Get("Content-Security-Policy"); got != expectedCsp { + t.Fatalf("expected Content-Security-Policy %q, got %q", expectedCsp, got) + } + if rec.Body.String() != string(content) { + t.Fatalf("unexpected body: %q", rec.Body.String()) + } + }) + + t.Run("Requesting a file with an unrecognized extension falls back to a generic Content-Type", func(t *testing.T) { + uploadDir := t.TempDir() + writeUploadedImageFile(t, uploadDir, "notes.txt", []byte("some notes")) + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/notes.txt", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" { + t.Fatalf("expected Content-Type application/octet-stream, got %q", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("unexpected X-Content-Type-Options: %q", got) + } + if got := rec.Header().Get("Content-Security-Policy"); got != "" { + t.Fatalf("expected no Content-Security-Policy header, got %q", got) + } + }) + + t.Run("Requesting a filename that was never uploaded answers 404, not the SPA", func(t *testing.T) { + uploadDir := t.TempDir() + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/missing.png", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("unexpected X-Content-Type-Options: %q", got) + } + if contains := rec.Body.String(); contains == spaIndexMarker { + t.Fatalf("expected body to not be the SPA index marker, got %q", contains) + } + }) + + t.Run("Requesting a file that was deleted after being stored answers 404", func(t *testing.T) { + uploadDir := t.TempDir() + filename := "4b9e2f1a-5555-4a11-9a11-555555555555.png" + writeUploadedImageFile(t, uploadDir, filename, []byte("temporary")) + if err := os.Remove(filepath.Join(uploadDir, filename)); err != nil { + t.Fatalf("failed to remove file: %v", err) + } + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+filename, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } + }) + + t.Run("Requesting a directory inside the upload directory answers 404", func(t *testing.T) { + uploadDir := t.TempDir() + writeUploadedImageFile(t, uploadDir, "nested/4b9e2f1a-6666-4a11-9a11-666666666666.png", []byte("nested-content")) + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/nested", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } + if body := rec.Body.String(); body != "404 page not found\n" { + t.Fatalf("expected no directory listing body, got %q", body) + } + }) + + t.Run("A path-traversal attempt never escapes the upload directory", func(t *testing.T) { + uploadDir := t.TempDir() + writeUploadedImageFile(t, uploadDir, "4b9e2f1a-7777-4a11-9a11-777777777777.png", []byte("inside-content")) + + outsideDir := t.TempDir() + sentinelName := "settings.go" + sentinelContent := []byte("package outside\n") + if err := os.WriteFile(filepath.Join(outsideDir, sentinelName), sentinelContent, 0o644); err != nil { + t.Fatalf("failed to write sentinel file: %v", err) + } + + router := newServeUploadedImagesRouter(uploadDir) + + traversalPaths := []string{ + "../" + sentinelName, + "..%2f" + sentinelName, + "..%2f..%2f..%2f" + sentinelName, + "%2e%2e%2f" + sentinelName, + } + for _, traversalPath := range traversalPaths { + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+traversalPath, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("traversal path %q: expected status 404, got %d", traversalPath, rec.Code) + } + if rec.Body.String() == string(sentinelContent) { + t.Fatalf("traversal path %q: sentinel content leaked", traversalPath) + } + } + }) + + t.Run("HEAD returns the same headers as GET with no body", func(t *testing.T) { + uploadDir := t.TempDir() + filename := "4b9e2f1a-8888-4a11-9a11-888888888888.webp" + writeUploadedImageFile(t, uploadDir, filename, []byte("webp-bytes")) + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodHead, "/static/uploadedImages/"+filename, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "image/webp" { + t.Fatalf("expected Content-Type image/webp, got %q", got) + } + if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { + t.Fatalf("unexpected Cache-Control: %q", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("unexpected X-Content-Type-Options: %q", got) + } + if rec.Body.Len() != 0 { + t.Fatalf("expected empty body, got %q", rec.Body.String()) + } + }) + + t.Run("No authentication is required to fetch an uploaded image", func(t *testing.T) { + uploadDir := t.TempDir() + filename := "4b9e2f1a-9999-4a11-9a11-999999999999.png" + writeUploadedImageFile(t, uploadDir, filename, []byte("png-bytes")) + router := newServeUploadedImagesRouter(uploadDir) + + req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+filename, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if rec.Code >= 300 && rec.Code < 400 { + t.Fatalf("expected no redirect, got status %d", rec.Code) + } + }) +}