From 6d9f9592b584921132909673e56293d60ee4d051 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 14:16:19 -0500 Subject: [PATCH] Fix #221: image pastes scale to fit, no code box, accurate size, raw serves image - paste view: image attachments render in a scalable preview (max-height min(70vh, 720px), object-fit contain) and the code box is omitted; wrap/copy buttons hidden for image pastes - SizeHuman/stats summary show the attachment size for file pastes (row.Content is empty for file pastes, was showing 0 B) - /raw/{id} on a file paste streams the attachment bytes inline with its sniffed mime and filename instead of an empty text/plain body --- internal/api/server.go | 32 +++++++++++++++++++++++++++++++ internal/web/static/app.css | 14 +++++++++++++- internal/web/templates/paste.html | 9 +++++++-- internal/web/web.go | 27 ++++++++++++++++++++++++-- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index dfe1ac4..5d3de98 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "html/template" + "io" "net/http" "os" "strconv" @@ -443,6 +444,37 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", 404) return } + // #221: file pastes store the bytes in the blob store, not row.Content. + // Raw on an image paste serves the attachment file itself (inline), the + // same as the paste-view preview link, instead of an empty body. + if row.Content == "" { + att, err := a.store.GetAttachmentForPaste(row.ID) + if err != nil { + http.Error(w, "db error", 500) + return + } + if att == nil { + http.Error(w, "not found", 404) + return + } + blobs := a.store.Blobs() + if blobs == nil { + http.Error(w, "blob storage unavailable", 500) + return + } + blob, err := blobs.Get(att.PasteID + "/" + att.SHA256) + if err != nil { + http.Error(w, "not found", 404) + return + } + defer blob.Close() + w.Header().Set("Content-Type", serveContentType(att.Mime)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", att.Filename)) + a.store.IncrementViews(row.ID, "", 0) + io.Copy(w, blob) + return + } // #49 decision: raw reads count against the read budget too, with the // same per-viewer dedupe window as page views. #58: a reader that loses // the burn claim must not receive the content. diff --git a/internal/web/static/app.css b/internal/web/static/app.css index c96034b..98f90c7 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -216,7 +216,8 @@ body { .tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; } .paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; } .paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; word-break: normal; overflow-wrap: anywhere; } -.stats-pill { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +/* #222: overflow:hidden clips the element's own 1px border thin at the radius; inset shadow isn't clipped */ +.stats-pill { border: 0; box-shadow: inset 0 0 0 1px var(--border); border-radius: var(--radius); overflow: hidden; } .stats-head { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; border-bottom: 1px solid var(--border); color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 14px 18px; cursor: pointer; text-align: left; } .stats-head:hover { color: var(--fg); background: var(--surface-2); } .stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; } @@ -819,6 +820,17 @@ button[type="submit"]:focus-visible, .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); } +/* #221: image pastes scale to fit the available box; no fixed pixel cap */ +.attachment-preview.is-image img { + display: block; + max-width: 100%; + max-height: min(70vh, 720px); + width: auto; + height: auto; + object-fit: contain; + 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..212b35f 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -6,9 +6,9 @@

{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}

{{if .CustomSlug}}/{{.CustomSlug}}{{end}}
- + {{if not .AttachmentIsImage}}{{end}} raw - copy + {{if not .AttachmentIsImage}}copy{{end}} {{if .DeletionToken}}delete{{end}} @@ -44,14 +44,19 @@ {{.Attachment.SizeHuman}} {{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}} + {{/* #221: image pastes scale to fit; no text/code box underneath */}} +
{{.Attachment.Filename}}
+ {{else}}
{{.Attachment.Filename}}
{{end}} {{end}} + {{if not .AttachmentIsImage}}{{/* #221: image pastes replace the code box entirely */}}
{{.Gutter}}
{{.ContentHTML}}
+ {{end}} diff --git a/internal/web/web.go b/internal/web/web.go index 0f87e16..39462c4 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -92,6 +92,25 @@ func humanSize(n int) string { return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024)) } +// #221: file pastes store their bytes in the blob store with empty text +// content; the displayed size is the attachment size, not len(Content). +func pasteDisplaySize(row *store.PasteRow, att *store.Attachment) int64 { + if row.Content == "" && att != nil { + return att.Size + } + return int64(len(row.Content)) +} + +// attachmentIsImage reports whether a paste-view attachment renders as an +// image (same set the paste template previews inline, #221). +func attachmentIsImage(mime string) bool { + switch strings.ToLower(strings.TrimSpace(mime)) { + case "image/png", "image/jpeg", "image/gif", "image/webp": + return true + } + return false +} + func (u *UI) StaticHandler() http.Handler { sub, _ := fs.Sub(staticFS, "static") return http.StripPrefix("/static/", http.FileServer(http.FS(sub))) @@ -245,20 +264,22 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC if lang == "" { lang = "text" } - summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt)) // #38: one optional file attachment per paste; nil when none. attachment, err := h.Store.GetAttachmentForPaste(row.ID) if err != nil { http.Error(w, "db error", 500) return } + summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(int(pasteDisplaySize(row, attachment))), row.ViewCount, agoString(row.CreatedAt)) data := map[string]any{ "Page": "paste", "ID": row.ID, "Title": row.Title.String, "Language": row.Language.String, "StatsSummary": summary, - "SizeHuman": humanSize(len(row.Content)), + // #221: file pastes store bytes in the blob store; report the + // attachment size, not the (empty) text content length. + "SizeHuman": humanSize(int(pasteDisplaySize(row, attachment))), "HasPassword": row.PasswordHash.Valid, "BurnAfterRead": row.BurnAfterRead, "CustomSlug": row.CustomSlug.String, @@ -279,6 +300,8 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC "ReadsTotal": int(row.ReadsLimit.Int64), "JustCreated": justCreated, "Attachment": attachment, + // #221: tells the template to drop the code box for image pastes + "AttachmentIsImage": attachment != nil && attachmentIsImage(attachment.Mime), "Host": "this host", } h.renderPage(w, "paste.html", data)