From 8901a3c82c162611e981c254f85e6079d7b9fccc Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 20:31:57 -0500 Subject: [PATCH] #281: /raw streams attachment blob for all attachment mimes handleRaw only streamed the blob behind an isImageMime gate (#221), so non-image attachment pastes fell through to empty row.Content and /raw served 0 bytes. Serve the blob for every attachment mime, passing the sniffed mime through serveContentType so active-content types (html, svg, xml) still serve as text/plain per the #34 rule. Regression tests cover text and html attachments (size, Content-Type, byte equality). --- internal/api/attachments.go | 11 ------ internal/api/attachments_test.go | 61 ++++++++++++++++++++++++++++++++ internal/api/server.go | 11 +++--- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/internal/api/attachments.go b/internal/api/attachments.go index 63fca22..862f544 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -91,17 +91,6 @@ func (l *limitReader) Read(p []byte) (int, error) { return n, err } -// isImageMime reports whether the sniffed mime is a raster image the viewer -// can render inline (#221). SVG is excluded: it is forced to text/plain on -// serving by the active-content rule and must never render as an image. -func isImageMime(mime string) bool { - switch mime { - case "image/png", "image/jpeg", "image/gif", "image/webp": - return true - } - return false -} - // handleCreatePasteMultipart implements POST /api/pastes with // multipart/form-data (#38). Fields mirror the JSON create path; a 'file' // part makes the paste a file paste (1 file = 1 paste: if text content is diff --git a/internal/api/attachments_test.go b/internal/api/attachments_test.go index c9840cc..7b96ac1 100644 --- a/internal/api/attachments_test.go +++ b/internal/api/attachments_test.go @@ -306,3 +306,64 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) { t.Fatalf("paste should require password, got %d", rec2.Code) } } + +// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes, +// not just raster images (the old isImageMime gate left non-image +// attachments serving an empty body from row.Content). +func TestRawStreamsNonImageAttachment(t *testing.T) { + s := testServer(t) + h := s.routes() + + body := []byte("hello, this is a plain text attachment body") + rec, resp := multipartCreate(t, h, "notes.txt", body, nil) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + if resp["attachment"] == nil { + t.Fatalf("no attachment in response: %v", resp) + } + id, _ := resp["id"].(string) + + req := httptest.NewRequest("GET", "/raw/"+id, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != 200 { + t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String()) + } + if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q", got) + } + if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("nosniff = %q", got) + } + if !bytes.Equal(rec2.Body.Bytes(), body) { + t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body)) + } +} + +// #281: active-content attachment types still get forced to text/plain on +// /raw, same rule as the /f/ serving path (#34). +func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) { + s := testServer(t) + h := s.routes() + + html := []byte("") + rec, resp := multipartCreate(t, h, "page.html", html, nil) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + id, _ := resp["id"].(string) + + req := httptest.NewRequest("GET", "/raw/"+id, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != 200 { + t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String()) + } + if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q", got) + } + if !bytes.Equal(rec2.Body.Bytes(), html) { + t.Fatal("raw bytes differ from upload") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index b36a714..787e637 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -496,15 +496,18 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", 404) return } - // #221: raw view of an image paste serves the image bytes themselves as - // an image, not the (empty) text content. - if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) { + // #221: raw view of a paste backed by an attachment serves the stored + // blob bytes with the sniffed mime, not the (empty) text content — for + // ALL attachment mimes (#281); /raw/{id} is the raw fetch for the file + // too. serveContentType still forces active-content types (html, svg, + // xml) to text/plain per the #34 rule below. + if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil { blobs := a.store.Blobs() if blobs != nil { if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil { defer blob.Close() a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95) - w.Header().Set("Content-Type", att.Mime) + w.Header().Set("Content-Type", serveContentType(att.Mime)) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size)) http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob) -- 2.54.0