Fix #221: image paste view #225
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -443,6 +444,37 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "not found", 404)
|
http.Error(w, "not found", 404)
|
||||||
return
|
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
|
// #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
|
// same per-viewer dedupe window as page views. #58: a reader that loses
|
||||||
// the burn claim must not receive the content.
|
// the burn claim must not receive the content.
|
||||||
|
|||||||
@@ -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; }
|
.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 { 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; }
|
.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 { 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-head:hover { color: var(--fg); background: var(--surface-2); }
|
||||||
.stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; }
|
.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:hover { border-color: var(--accent); }
|
||||||
.attachment-chip .attachment-size { color: var(--muted-fg); font-size: 19px; }
|
.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); }
|
.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') */
|
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
|
||||||
.hidden { display: none; }
|
.hidden { display: none; }
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
||||||
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
|
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button>
|
{{if not .AttachmentIsImage}}<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button>{{end}}
|
||||||
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
||||||
<a class="iconbtn" href="#" id="copy-btn">copy</a>
|
{{if not .AttachmentIsImage}}<a class="iconbtn" href="#" id="copy-btn">copy</a>{{end}}
|
||||||
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">delete</a>{{end}}
|
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">delete</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,14 +44,19 @@
|
|||||||
<span class="attachment-size">{{.Attachment.SizeHuman}}</span>
|
<span class="attachment-size">{{.Attachment.SizeHuman}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}}
|
{{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 */}}
|
||||||
|
<div class="attachment-preview is-image"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
|
||||||
|
{{else}}
|
||||||
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
|
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if not .AttachmentIsImage}}{{/* #221: image pastes replace the code box entirely */}}
|
||||||
<div class="float">
|
<div class="float">
|
||||||
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
|
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
||||||
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
|
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
|
||||||
|
|||||||
+25
-2
@@ -92,6 +92,25 @@ func humanSize(n int) string {
|
|||||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
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 {
|
func (u *UI) StaticHandler() http.Handler {
|
||||||
sub, _ := fs.Sub(staticFS, "static")
|
sub, _ := fs.Sub(staticFS, "static")
|
||||||
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
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 == "" {
|
if lang == "" {
|
||||||
lang = "text"
|
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.
|
// #38: one optional file attachment per paste; nil when none.
|
||||||
attachment, err := h.Store.GetAttachmentForPaste(row.ID)
|
attachment, err := h.Store.GetAttachmentForPaste(row.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", 500)
|
http.Error(w, "db error", 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(int(pasteDisplaySize(row, attachment))), row.ViewCount, agoString(row.CreatedAt))
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Page": "paste",
|
"Page": "paste",
|
||||||
"ID": row.ID,
|
"ID": row.ID,
|
||||||
"Title": row.Title.String,
|
"Title": row.Title.String,
|
||||||
"Language": row.Language.String,
|
"Language": row.Language.String,
|
||||||
"StatsSummary": summary,
|
"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,
|
"HasPassword": row.PasswordHash.Valid,
|
||||||
"BurnAfterRead": row.BurnAfterRead,
|
"BurnAfterRead": row.BurnAfterRead,
|
||||||
"CustomSlug": row.CustomSlug.String,
|
"CustomSlug": row.CustomSlug.String,
|
||||||
@@ -279,6 +300,8 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC
|
|||||||
"ReadsTotal": int(row.ReadsLimit.Int64),
|
"ReadsTotal": int(row.ReadsLimit.Int64),
|
||||||
"JustCreated": justCreated,
|
"JustCreated": justCreated,
|
||||||
"Attachment": attachment,
|
"Attachment": attachment,
|
||||||
|
// #221: tells the template to drop the code box for image pastes
|
||||||
|
"AttachmentIsImage": attachment != nil && attachmentIsImage(attachment.Mime),
|
||||||
"Host": "this host",
|
"Host": "this host",
|
||||||
}
|
}
|
||||||
h.renderPage(w, "paste.html", data)
|
h.renderPage(w, "paste.html", data)
|
||||||
|
|||||||
Reference in New Issue
Block a user