From 98ea7eefa92aa2a45a50a5d7e7c07544c9a0cca3 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 14:16:38 -0500 Subject: [PATCH] fix #221: scale image pastes, drop text box, fix raw view and size - image pastes render the image scaled to fit the viewer box (aspect ratio preserved, max-height 70vh), no text/code box below it - link pill moved under the image as a small inline chip - /raw serves image attachment bytes as an image instead of empty text - view details size reports the actual attachment file size --- internal/api/attachments.go | 11 +++++++++++ internal/api/server.go | 16 ++++++++++++++++ internal/web/static/app.css | 32 +++++++++++++++++++++++++------ internal/web/templates/paste.html | 10 +++++++++- internal/web/web.go | 20 +++++++++++++++++-- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/internal/api/attachments.go b/internal/api/attachments.go index 862f544..63fca22 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -91,6 +91,17 @@ 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/server.go b/internal/api/server.go index dfe1ac4..5a9128f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -451,6 +451,22 @@ 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) { + 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("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) + return + } + } + } // #34: content_type is attacker-controlled via the create API. Serving it // verbatim let a paste be stored with text/html (or image/svg+xml) and // render as active content on this origin when fetched from /raw — diff --git a/internal/web/static/app.css b/internal/web/static/app.css index e5235ef..f734d4f 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -813,15 +813,35 @@ button[type="submit"]:focus-visible, } .file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); } .attachment-bar { display: flex; flex-direction: column; gap: 10px; } -.attachment-chip { +/* #221: the link pill under an image preview stays a small inline chip, + left-aligned under the image, not stretched above it. */ +.attachment-bar .attachment-chip { display: inline-flex; align-items: center; gap: 12px; align-self: flex-start; border: 1px solid var(--border); border-radius: var(--radius); - padding: 8px 16px; text-decoration: none; color: var(--fg); - background: var(--surface-2); font-size: 21.6px; + padding: 6px 14px; text-decoration: none; color: var(--fg); + background: var(--surface-2); font-size: 19px; +} +.attachment-bar .attachment-chip:hover { border-color: var(--accent); } +.attachment-bar .attachment-chip .attachment-name { + max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* #221: images scale to fit the viewer box, aspect ratio preserved. */ +.attachment-preview { + max-width: 100%; + align-self: flex-start; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + overflow: hidden; +} +.attachment-preview img { + display: block; + max-width: 100%; + max-height: 70vh; + width: auto; + height: auto; + object-fit: contain; } -.attachment-chip:hover { border-color: var(--accent); } -.attachment-chip .attachment-size { color: var(--muted-fg); font-size: 19px; } -.attachment-preview img { max-width: 480px; max-height: 360px; border-radius: var(--radius); border: 1px solid var(--border); } /* #139: CSP-safe replacements for inline style attributes (style-src 'self') */ .hidden { display: none; } diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index b907ded..b549f3b 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -39,19 +39,27 @@ {{if .Attachment}}
+ {{if .AttachmentImage}} +
{{.Attachment.Filename}}
+ + {{.Attachment.Filename}} + {{.Attachment.SizeHuman}} + + {{else}} {{.Attachment.Filename}} {{.Attachment.SizeHuman}} - {{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}}
{{.Attachment.Filename}}
{{end}}
{{end}} + {{if not .AttachmentImage}}
{{.Gutter}}
{{.ContentHTML}}
+ {{end}} diff --git a/internal/web/web.go b/internal/web/web.go index 0f87e16..08f84ce 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -252,20 +252,35 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC http.Error(w, "db error", 500) return } + // #221: image attachments render the image, not a text/code box. Size + // comes from the attachment's actual file size, not the text content. + attImage := false + if attachment != nil { + switch attachment.Mime { + case "image/png", "image/jpeg", "image/gif", "image/webp": + attImage = true + } + } + sizeHuman := humanSize(len(row.Content)) + lineCount := lines + if attachment != nil { + sizeHuman = attachment.SizeHuman + lineCount = 1 + } data := map[string]any{ "Page": "paste", "ID": row.ID, "Title": row.Title.String, "Language": row.Language.String, "StatsSummary": summary, - "SizeHuman": humanSize(len(row.Content)), + "SizeHuman": sizeHuman, "HasPassword": row.PasswordHash.Valid, "BurnAfterRead": row.BurnAfterRead, "CustomSlug": row.CustomSlug.String, "ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text "ContentAttr": row.Content, "Gutter": strings.TrimSuffix(gutter, "\n"), - "LineCount": lines, + "LineCount": lineCount, "SizeBytes": len(row.Content), "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt, @@ -279,6 +294,7 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC "ReadsTotal": int(row.ReadsLimit.Int64), "JustCreated": justCreated, "Attachment": attachment, + "AttachmentImage": attImage, "Host": "this host", } h.renderPage(w, "paste.html", data)