From 8474b8eb02b9dd8cafb2f2b51c4eadc96927b536 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:15:05 -0500 Subject: [PATCH 01/17] Cap attachment filenames at 128 chars server-side A 250-char multipart filename was accepted and echoed verbatim in Content-Disposition. SanitizeFilename already truncates; lower the cap from 255 to 128 so DB rows and header echoes stay bounded (#248). --- internal/store/attachment.go | 5 ++++- internal/store/blob_test.go | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/store/attachment.go b/internal/store/attachment.go index a944cb3..70981ab 100644 --- a/internal/store/attachment.go +++ b/internal/store/attachment.go @@ -26,7 +26,10 @@ type Attachment struct { SizeHuman string `json:"-"` // template-only: human-readable size } -const MaxFilenameLen = 255 +// MaxFilenameLen caps stored attachment filenames (bytes) to bound DB +// rows and Content-Disposition echoes. 128 keeps names readable while +// stopping filename-bloat abuse; longer names truncate. +const MaxFilenameLen = 128 // ErrFileTooLarge is returned when an attachment exceeds the per-file cap. var ErrFileTooLarge = errors.New("file too large") diff --git a/internal/store/blob_test.go b/internal/store/blob_test.go index 70a20c8..1eba5a7 100644 --- a/internal/store/blob_test.go +++ b/internal/store/blob_test.go @@ -105,4 +105,9 @@ func TestSanitizeFilename(t *testing.T) { if got := SanitizeFilename(long); len(got) != MaxFilenameLen { t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen) } + // issue #248: a 250-char multipart filename must truncate to the cap + repro := strings.Repeat("b", 246) + ".txt" + if got := SanitizeFilename(repro); len(got) != MaxFilenameLen { + t.Errorf("repro name len = %d want %d", len(got), MaxFilenameLen) + } } From bb2c5e200ccf670b468601e699afdcc366a10e78 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:16:03 -0500 Subject: [PATCH 02/17] #249: hide code block for all attachment pastes, not only images --- internal/web/templates/paste.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index 74456cd..4465632 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -50,7 +50,7 @@ {{end}} - {{if not .AttachmentImage}} + {{if not .Attachment}}
{{.Gutter}}
{{.ContentHTML}}
From d25e20733eeca02b8fc226256a9bf201f2d66ef2 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:24:00 -0500 Subject: [PATCH 03/17] #255: widen history URL column with dedicated col-url class --- internal/web/static/app.css | 3 +++ internal/web/templates/history.html | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 389c78f..20b6c3d 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -869,6 +869,9 @@ button[type="submit"]:focus-visible, .col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } .col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; } .col-f { width: 100px; } .col-g { width: 190px; } +/* #255: history's URL column had its own narrow width (col-f doubles as + /mine's ID column); give it a dedicated class. */ +.col-url { width: 150px; } /* #210: /mine rows render a delete button cell that had no declared column, so under table-layout:fixed it overlapped the ID column. */ .col-del { width: 64px; } diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index 88359c7..2896a62 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -8,7 +8,7 @@
- + From a4e1abfaae824a146831b5fab9206fbef874a60b Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:29:02 -0500 Subject: [PATCH 04/17] #260: replace Success! feedback text with a checkmark so buttons do not resize --- internal/web/static/new.js | 2 +- internal/web/static/paste.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/web/static/new.js b/internal/web/static/new.js index 3e4cf2c..41ef6a1 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -197,7 +197,7 @@ function finishCreate(data) { try { navigator.clipboard.writeText(url); copyBtn.classList.add('ok'); // in-place success feedback (#53) - copyBtn.textContent = 'Success!'; + copyBtn.textContent = '✓'; setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000); } catch(e) { toast('Copy failed', 'error'); } }); diff --git a/internal/web/static/paste.js b/internal/web/static/paste.js index 0e88715..eec6c26 100644 --- a/internal/web/static/paste.js +++ b/internal/web/static/paste.js @@ -20,7 +20,7 @@ function copyFeedback(btn) { if (!btn) return; if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link) btn.classList.add('ok'); - btn.textContent = 'Success!'; + btn.textContent = '✓'; clearTimeout(btn._okh); btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = btn.dataset.label; }, 2000); } From ee0cd7dcbd86bd3a6f7effde03270a96ad34ea51 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:30:03 -0500 Subject: [PATCH 05/17] new page: sync editor gutter with textarea scroll (#259) --- internal/web/static/new.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/web/static/new.js b/internal/web/static/new.js index 3e4cf2c..8954133 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -9,6 +9,8 @@ function updateGutter() { gutter.textContent = s; } content.addEventListener('input', updateGutter); +// #259: the editor scrolls itself; keep the gutter's numbers in step with it. +content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; }); updateGutter(); function toast(msg, kind) { From c91d0e53ca63138412dd0756e73e69506d6fbc1e Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:30:23 -0500 Subject: [PATCH 06/17] #256: rename page URLs to match nav labels (/public, /saved) --- internal/api/issue138_cookie_test.go | 2 +- internal/api/mine_test.go | 4 +-- internal/api/routes_rename_test.go | 52 ++++++++++++++++++++++++++++ internal/api/server.go | 9 +++-- internal/store/customslug.go | 2 +- internal/web/static/paste.js | 2 +- internal/web/templates/layout.html | 6 ++-- internal/web/web.go | 8 ++--- 8 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 internal/api/routes_rename_test.go diff --git a/internal/api/issue138_cookie_test.go b/internal/api/issue138_cookie_test.go index d5e8ca2..93459be 100644 --- a/internal/api/issue138_cookie_test.go +++ b/internal/api/issue138_cookie_test.go @@ -29,7 +29,7 @@ func newTestServer138(t *testing.T) *httptest.ResponseRecorder { globalSettingsFn = ss.get t.Cleanup(func() { globalSettingsFn = nil }) a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} - req := httptest.NewRequest("GET", "/history", nil) + req := httptest.NewRequest("GET", "/public", nil) rec := httptest.NewRecorder() a.routes().ServeHTTP(rec, req) return rec diff --git a/internal/api/mine_test.go b/internal/api/mine_test.go index 233e5a5..c3adcfd 100644 --- a/internal/api/mine_test.go +++ b/internal/api/mine_test.go @@ -58,7 +58,7 @@ func TestMineCreateListDelete(t *testing.T) { a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} h := a.routes() - alice := viewerCookieFor(t, h, "/history") + alice := viewerCookieFor(t, h, "/public") if alice == "" { t.Fatal("no viewer cookie issued") } @@ -89,7 +89,7 @@ func TestMineCreateListDelete(t *testing.T) { } // a different browser's cookie does NOT see it - bob := viewerCookieFor(t, h, "/history") + bob := viewerCookieFor(t, h, "/public") rec = doReq(t, h, "GET", "/api/mine", bob, "") json.Unmarshal(rec.Body.Bytes(), &list) if list.Total != 0 { diff --git a/internal/api/routes_rename_test.go b/internal/api/routes_rename_test.go new file mode 100644 index 0000000..6a02ec0 --- /dev/null +++ b/internal/api/routes_rename_test.go @@ -0,0 +1,52 @@ +package api + +// #256: renamed page routes; old URLs redirect. +import ( + "palette/internal/store" + "palette/internal/web" + + "net/http" + "net/http/httptest" + "testing" +) + +func TestRenamedPageRoutes(t *testing.T) { + globalLimiter = newLimiter() // fresh rate-limit buckets + st, err := store.OpenStore(":memory:") + if err != nil { + t.Fatal(err) + } + ui, err := web.New() + if err != nil { + t.Fatal(err) + } + cfg := Config{MaxTextBytes: 5 * 1024 * 1024} + ss := NewTestSettingsStore(t, cfg) + globalSettingsFn = ss.get + t.Cleanup(func() { globalSettingsFn = nil }) + a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} + h := a.routes() + + // new routes render pages + for _, path := range []string{"/public", "/saved"} { + req := httptest.NewRequest("GET", path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s: %d, want 200", path, rec.Code) + } + } + + // old routes redirect + for _, tc := range [][2]string{{"/history", "/public"}, {"/mine", "/saved"}, {"/", "/public"}} { + req := httptest.NewRequest("GET", tc[0], nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusFound { + t.Fatalf("GET %s: %d, want redirect", tc[0], rec.Code) + } + if loc := rec.Header().Get("Location"); loc != tc[1] { + t.Fatalf("GET %s redirects to %s, want %s", tc[0], loc, tc[1]) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index aae5c96..b36a714 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -118,11 +118,14 @@ func (a *apiServer) routes() http.Handler { r.Get("/raw/{id}", a.handleRaw) // web pages - r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP) + r.Get("/", http.RedirectHandler("/public", http.StatusFound).ServeHTTP) r.Get("/new", a.ui.Handlers().HandleNewPage) - r.Get("/history", a.ui.Handlers().HandleHistoryPage) + r.Get("/public", a.ui.Handlers().HandleHistoryPage) + r.Get("/saved", a.ui.Handlers().HandleMinePage) r.Get("/settings", a.ui.Handlers().HandleSettingsPage) - r.Get("/mine", a.ui.Handlers().HandleMinePage) + // #256: old URLs redirect to the renamed pages + r.Get("/history", http.RedirectHandler("/public", http.StatusMovedPermanently).ServeHTTP) + r.Get("/mine", http.RedirectHandler("/saved", http.StatusMovedPermanently).ServeHTTP) r.Handle("/static/*", a.ui.StaticHandler()) r.Get("/unlock/{id}", a.handlePasteView) r.Post("/unlock/{id}", a.handlePasteView) diff --git a/internal/store/customslug.go b/internal/store/customslug.go index 65f0ae4..e39b378 100644 --- a/internal/store/customslug.go +++ b/internal/store/customslug.go @@ -13,7 +13,7 @@ var reservedSlugs = map[string]bool{ "api": true, "raw": true, "can": true, "cans": true, "public": true, "history": true, "static": true, "assets": true, "favicon.ico": true, "new": true, "login": true, "logout": true, "admin": true, "settings": true, - "mine": true, "unlock": true, "guess": true, "f": true, + "mine": true, "saved": true, "unlock": true, "guess": true, "f": true, } var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit") diff --git a/internal/web/static/paste.js b/internal/web/static/paste.js index 0e88715..3b48dd4 100644 --- a/internal/web/static/paste.js +++ b/internal/web/static/paste.js @@ -42,7 +42,7 @@ function redeem() { try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {} if (!tok) { alert('deletion token not available in this browser'); return; } fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}}) - .then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); }); + .then(r => { if (r.ok) location.href = '/public'; else alert('delete failed'); }); } // wiring (moved from inline handlers for CSP #139) diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index b38e262..61eeca0 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -8,11 +8,11 @@ {{define "topbar"}}
- +
diff --git a/internal/web/web.go b/internal/web/web.go index cbd71a7..049817d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -433,9 +433,9 @@ func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) { h.renderPage(w, "new.html", map[string]any{"Page": "new"}) } -// HandleHistoryPage serves /history. +// HandleHistoryPage serves /public. func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) { - h.renderPage(w, "history.html", map[string]any{"Page": "history"}) + h.renderPage(w, "history.html", map[string]any{"Page": "public"}) } // HandleSettingsPage serves /settings. @@ -452,9 +452,9 @@ func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes}) } -// HandleMinePage serves /mine. +// HandleMinePage serves /saved. func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) { - h.renderPage(w, "mine.html", map[string]any{"Page": "mine"}) + h.renderPage(w, "mine.html", map[string]any{"Page": "saved"}) } // HandleAdminPage serves /admin. From f94a813d79440e9c3ac2a4d68863be8eed209e1f Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:35:52 -0500 Subject: [PATCH 07/17] #261: keep line number gutter visible during horizontal scroll - move the horizontal scroll from the .code flex container to the codebody so the pinned gutter stays in view --- internal/web/static/app.css | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 389c78f..16276d6 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -299,8 +299,14 @@ html[data-wrap] .float { overflow-x: hidden; } .code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } .code { font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh); - padding: 14px 0; display: flex; overflow-x: auto; + padding: 14px 0; display: flex; overflow-x: hidden; } +/* #261: horizontal scroll must live on the codebody, not the .code flex + container — a container-level scroll takes the gutter with it when the + user scrolls long lines. The gutter sits OUTSIDE the scroll container and + stays visible; the codebody shrinks to the remaining space and scrolls + (min-width: 0 lets it shrink below its content width inside the flex row). */ +.code .codebody { flex: 1 1 auto; min-width: 0; overflow-x: auto; } /* #167: the gutter must not drive the flex layout — its content width (row count × number width) shrinks the code column, which re-wraps lines, which grows the gutter: a feedback loop. Pin the gutter with a fixed @@ -310,7 +316,7 @@ html[data-wrap] .float { overflow-x: hidden; } .code .gutter { flex-shrink: 0; } /* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */ .code .gutter { padding-top: 0; padding-bottom: 0; } -.codebody { padding: 0 18px; white-space: pre; } +.codebody { padding: 0 18px; white-space: pre; overflow-x: auto; } /* #167: each logical line is its own block so offsetTop identifies its first visual row */ .codeline { display: block; } /* #167 rev: gutter number spans must stack one per visual row (wrap on) */ From 4050f1362e5e3a87897d6107e16c3e53736090c2 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:37:27 -0500 Subject: [PATCH 08/17] #257: size the paste gutter to the widest line number The paste gutter was pinned to a fixed 3ch width. With box-sizing: border-box that leaves only ~19px of content after the 10px+10px side padding, so 2+ digit line numbers overflow right into the code text (owner-visible from line 10, worst at 100+). paste-lines.js now sets the gutter width to calc(Nch + 20px), where N is the digit count of the highest line number, via CSSOM (CSP forbids inline style attributes). Numbers were already right-aligned; the column now matches the width of the biggest number. The width is only written when it changes, so the resize-observer/renumber loop keeps a stable fixed point. --- internal/web/static/paste-lines.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/web/static/paste-lines.js b/internal/web/static/paste-lines.js index e385e9d..fc6aabb 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -44,6 +44,23 @@ // measured, not derived from span counts or heights. function renumber() { var lines = body.querySelectorAll('.codeline'); + // #257: size the gutter column to the widest line number so numbers in + // the 100s+ fit their own column instead of bleeding into the code text. + // The gutter is box-sizing: border-box, so the column width must be the + // digits PLUS the 10px left + 10px right padding; at the CSS default 3ch + // the padding alone leaves only ~19px of content, and any 2+ digit + // number overflows into the code. Numbers are right-aligned, and the + // width below fits the widest number exactly. Set via CSSOM (CSP + // forbids inline style attributes). Only touch the width when it + // changes: the resize observer below re-runs renumber() when the gutter + // width reflows the code column, and rewriting the same value would + // ping-pong the fixed point forever. + var digits = String(lines.length || 1).length; + var w = 'calc(' + digits + 'ch + 20px)'; + if (gutter.style.width !== w) { + gutter.style.minWidth = w; + gutter.style.width = w; + } if (!wrapOn() || !lines.length) { // wrap OFF: one number per logical line (pre-existing behavior, // including the gutter scrolling with horizontal scroll). From 11cf7428eb9a226fa43454f16c08344c3135205e Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:47:34 -0500 Subject: [PATCH 09/17] #260: pin button width during copy feedback so neighbors never jump --- internal/web/static/new.js | 5 ++++- internal/web/static/paste.js | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/web/static/new.js b/internal/web/static/new.js index fb1d1a1..df2c191 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -199,8 +199,11 @@ function finishCreate(data) { try { navigator.clipboard.writeText(url); copyBtn.classList.add('ok'); // in-place success feedback (#53) + // #260: pin the pre-swap width so the wider/narrower glyph never + // shifts neighbouring elements; release it when the label restores. + copyBtn.style.minWidth = Math.ceil(copyBtn.getBoundingClientRect().width) + 'px'; copyBtn.textContent = '✓'; - setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000); + setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; copyBtn.style.minWidth = ''; }, 2000); } catch(e) { toast('Copy failed', 'error'); } }); // token carried via sessionStorage, never in the URL (#143) diff --git a/internal/web/static/paste.js b/internal/web/static/paste.js index a32b5e3..365f986 100644 --- a/internal/web/static/paste.js +++ b/internal/web/static/paste.js @@ -19,6 +19,12 @@ function toggleStats() { function copyFeedback(btn) { if (!btn) return; if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link) + // #260: keep the button width static during the feedback so surrounding + // elements never jump. Pin the pre-swap width, release it on restore. + if (!btn.dataset.pinned) { + btn.style.minWidth = Math.ceil(btn.getBoundingClientRect().width) + 'px'; + btn.dataset.pinned = '1'; + } btn.classList.add('ok'); btn.textContent = '✓'; clearTimeout(btn._okh); From a4118b92a9ae33db0b45cc0371ac952a0b4234dd Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 14:49:02 -0500 Subject: [PATCH 10/17] #267: jump to top/bottom buttons for long pastes and the editor Fixed-position Top/Bottom pills appear only when content exceeds 2x viewport height (window scroll on paste view, textarea scroll on /new). New static/jump.js drives them; markup added to paste.html and new.html. --- internal/web/static/app.css | 14 +++++++++++ internal/web/static/jump.js | 41 +++++++++++++++++++++++++++++++ internal/web/templates/new.html | 5 ++++ internal/web/templates/paste.html | 5 ++++ 4 files changed, 65 insertions(+) create mode 100644 internal/web/static/jump.js diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 389c78f..ba4e7f5 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -884,3 +884,17 @@ button[type="submit"]:focus-visible, .created-banner { display: block; } /* #167: gutter rows for wrapped paste view — one row per visual code line */ .gutline { display: block; } + +/* #267: jump to top/bottom pills for long pastes and the editor. + Hidden unless JS (jump.js) detects content more than 2x the viewport. */ +.jumpnav { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 50; + display: flex; + flex-direction: column; + gap: 8px; +} +.jumpnav.hidden { display: none; } +.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); } diff --git a/internal/web/static/jump.js b/internal/web/static/jump.js new file mode 100644 index 0000000..03fb611 --- /dev/null +++ b/internal/web/static/jump.js @@ -0,0 +1,41 @@ +/* #267: jump to top / bottom controls for long content. + Paste view scrolls the window; the /new editor scrolls its textarea. + The active scroller is chosen via data-jump-scroll on the script tag. */ +(function () { + var nav = document.getElementById('jumpnav'); + if (!nav) return; + var scroller = window; + var sel = nav.dataset.jumpScroll; + if (sel) scroller = document.querySelector(sel); + + function el() { + return scroller === window ? document.scrollingElement : scroller; + } + function isLarge() { + var e = el(); + if (!e) return false; + var visible = scroller === window ? window.innerHeight : e.clientHeight; + return e.scrollHeight > visible * 2; + } + function refresh() { + nav.classList.toggle('hidden', !isLarge()); + } + function jump(toTop) { + var e = el(); + if (!e) return; + if (scroller === window) { + window.scrollTo({ top: toTop ? 0 : e.scrollHeight }); + } else { + e.scrollTop = toTop ? 0 : e.scrollHeight; + } + } + nav.addEventListener('click', function (ev) { + var b = ev.target.closest('[data-jump]'); + if (!b) return; + ev.preventDefault(); + jump(b.dataset.jump === 'top'); + }); + window.addEventListener('resize', refresh); + if (scroller !== window && scroller) scroller.addEventListener('input', refresh); + refresh(); +})(); diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index ef90b5f..e21941f 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -33,6 +33,10 @@
+
@@ -90,4 +94,5 @@
+ {{template "foot" .}} diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index 4465632..f035074 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -55,8 +55,13 @@
{{.Gutter}}
{{.ContentHTML}}
{{end}} + + {{template "foot" .}} From 75264ee9f48db2e2b46eafa59a7e52bcda68b07f Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 17:13:28 -0500 Subject: [PATCH 11/17] #260 (fix attempt 2): static-width copy feedback via .swapbtn --- internal/web/static/app.css | 14 ++++++++++++++ internal/web/static/new.js | 10 ++++------ internal/web/static/paste.js | 13 ++++--------- internal/web/templates/paste.html | 4 ++-- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index a18cc9e..e97c58c 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -586,6 +586,20 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } border-color: var(--ok); } +/* #260: static-width success feedback. Label and checkmark stack in one + grid cell, so the button is always as wide as the wider of the two and + never shifts on click. Feedback is a pure .ok class toggle. */ +.swapbtn { + display: inline-grid; +} +.swapbtn > * { + grid-area: 1 / 1; + justify-self: center; +} +.swapbtn .swap-check { visibility: hidden; } +.swapbtn.ok .swap-check { visibility: visible; } +.swapbtn.ok .swap-label { visibility: hidden; } + /* headings: unified treatment (mirrors .side-section h3) */ .settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 { letter-spacing: -0.01em; diff --git a/internal/web/static/new.js b/internal/web/static/new.js index df2c191..0782762 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -192,18 +192,16 @@ async function create() { // button, password auto-unlock, then redirect to the paste. function finishCreate(data) { const url = location.origin + '/' + (data.custom_slug || data.id); - showResult('' + url + ' ', 'ok'); + // #260 attempt 2: .swapbtn markup — label and checkmark share one grid + // cell, so the button width is static and feedback is a class toggle. + showResult('' + url + ' ', 'ok'); $('result').dataset.token = data.deletion_token || ''; const copyBtn = document.getElementById('result-copy'); copyBtn.addEventListener('click', () => { try { navigator.clipboard.writeText(url); copyBtn.classList.add('ok'); // in-place success feedback (#53) - // #260: pin the pre-swap width so the wider/narrower glyph never - // shifts neighbouring elements; release it when the label restores. - copyBtn.style.minWidth = Math.ceil(copyBtn.getBoundingClientRect().width) + 'px'; - copyBtn.textContent = '✓'; - setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; copyBtn.style.minWidth = ''; }, 2000); + setTimeout(() => copyBtn.classList.remove('ok'), 2000); } catch(e) { toast('Copy failed', 'error'); } }); // token carried via sessionStorage, never in the URL (#143) diff --git a/internal/web/static/paste.js b/internal/web/static/paste.js index 365f986..cc4fb4f 100644 --- a/internal/web/static/paste.js +++ b/internal/web/static/paste.js @@ -18,17 +18,12 @@ function toggleStats() { } function copyFeedback(btn) { if (!btn) return; - if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link) - // #260: keep the button width static during the feedback so surrounding - // elements never jump. Pin the pre-swap width, release it on restore. - if (!btn.dataset.pinned) { - btn.style.minWidth = Math.ceil(btn.getBoundingClientRect().width) + 'px'; - btn.dataset.pinned = '1'; - } + // #260 attempt 2: .swapbtn stacks the label and checkmark in the same grid + // cell, so the button width is always the wider of the two and never moves. + // Feedback is a pure class toggle; no width pinning, no textContent swap. btn.classList.add('ok'); - btn.textContent = '✓'; clearTimeout(btn._okh); - btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = btn.dataset.label; }, 2000); + btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000); } function copyContent(btn) { navigator.clipboard.writeText(document.getElementById('raw-content').value) diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index f035074..25a2d2e 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -8,8 +8,8 @@
Raw - Link - Copy + Link + Copy {{if .DeletionToken}}Delete{{end}} From 714b4691e94f3e2c8cd96e35de23dd1eed01bed7 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 18:44:01 -0500 Subject: [PATCH 12/17] #273: theme-aware scrollbars via scrollbar-width/scrollbar-color + webkit fallbacks --- internal/web/static/app.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index e97c58c..16a77c1 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -921,3 +921,19 @@ button[type="submit"]:focus-visible, } .jumpnav.hidden { display: none; } .jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); } + +/* #273: theme-aware scrollbars. Standard properties first (Firefox, and + Chromium >= 121 honors scrollbar-color), then ::-webkit rules for finer + Chromium styling. Colors come from CSS vars so they track the preset. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 5px; +} +::-webkit-scrollbar-thumb:hover { background: var(--muted-fg); } +::-webkit-scrollbar-corner { background: transparent; } From 7fdb3ee61cd3ae51089c72407fd7836f0971eeee Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 18:50:08 -0500 Subject: [PATCH 13/17] #274: align editor line numbers with wrapped text rows With wrap on, a logical line occupies several visual rows in the textarea but the gutter showed one number per logical line, so every number after the first wrapped line drifted off its text (the paste view fixed this in #167; the editor gutter did not). Measure the wrapped row count per logical line with a hidden mirror div sharing the editor's font and wrapping rules, and render one .gutline block per visual row with the number on the first row of its logical line. Re-measure on input, wrap toggle and resize. Verified: gutter scrollHeight == textarea scrollHeight with zero diff at 1400x900 and 375x812, wrap on and off. --- internal/web/static/app.css | 6 ++-- internal/web/static/new.js | 66 ++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index e97c58c..c239d8a 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -319,8 +319,10 @@ html[data-wrap] .float { overflow-x: hidden; } .codebody { padding: 0 18px; white-space: pre; overflow-x: auto; } /* #167: each logical line is its own block so offsetTop identifies its first visual row */ .codeline { display: block; } -/* #167 rev: gutter number spans must stack one per visual row (wrap on) */ -.code .gutter .gutline { display: block; } +/* #167: gutter number spans must stack one per visual row (wrap on). + #274: the /new editor gutter uses the same .gutline blocks when its own + wrap toggle is on, so scope the rule to any gutter, not just .code. */ +.code .gutter .gutline, .editor-wrap .gutter .gutline { display: block; } /* #194: codeline blocks are adjacent (no '\n' text between them), so an empty block (blank source line) needs its own line box to stay one row */ .codeline:empty::before { content: "\200B"; } diff --git a/internal/web/static/new.js b/internal/web/static/new.js index 0782762..3b4453f 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -2,13 +2,71 @@ const $ = id => document.getElementById(id); const content = $('content'), gutter = $('gutter'); +// #274: with wrap on, a logical line occupies several VISUAL rows in the +// textarea, so one number per logical line drifts off its text (same bug the +// paste view fixed in #167). A textarea can't be split into spans, so the +// wrapped row count per logical line is measured with a hidden mirror div +// that shares the editor's font, line metrics and wrapping rules, and the +// gutter renders one .gutline block per visual row with the number on the +// FIRST row of its logical line (fillers elsewhere). +let mirror = null; +function measureRows(lines) { + if (!mirror) { + mirror = document.createElement('div'); + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.top = '0'; + mirror.style.left = '-9999px'; + document.body.appendChild(mirror); + } + const cs = getComputedStyle(content); + mirror.style.font = cs.font; + mirror.style.lineHeight = cs.lineHeight; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.overflowWrap = 'anywhere'; + mirror.style.wordBreak = 'break-all'; + mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px'; + const lh = parseFloat(cs.lineHeight) || 1; + const starts = []; + let total = 0; + const n = Math.max(lines.length, 1); + for (let i = 0; i < n; i++) { + // A trailing newline yields an empty last line: it still occupies one row. + mirror.textContent = lines[i] + '\n'; + let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh)); + starts.push(total); + total += rows; + } + return { starts, total }; +} + function updateGutter() { - const lines = content.value.split('\n').length; - let s = ''; - for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n'; - gutter.textContent = s; + const lines = content.value.split('\n'); + const n = Math.max(lines.length, 1); + if (!document.documentElement.hasAttribute('data-wrap')) { + let s = ''; + for (let i = 1; i <= n; i++) s += i + '\n'; + gutter.textContent = s.slice(0, -1); + return; + } + const { starts, total } = measureRows(lines); + gutter.textContent = ''; + const frag = document.createDocumentFragment(); + const spans = []; + for (let r = 0; r < total; r++) { + const c = document.createElement('span'); + c.className = 'gutline'; + c.textContent = '\u00a0'; + spans.push(c); + frag.appendChild(c); + } + gutter.appendChild(frag); + for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1); } content.addEventListener('input', updateGutter); +// #274: the wrap toggle and width changes re-wrap the textarea; re-measure. +new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] }); +window.addEventListener('resize', updateGutter); // #259: the editor scrolls itself; keep the gutter's numbers in step with it. content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; }); updateGutter(); From f3fe2335d419ec67697a3fd27d2989bef7c0ea9c Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 20:10:25 -0500 Subject: [PATCH 14/17] Release v0.5.0: README refresh - Add code-viewer polish line to the feature list (pinned gutter sized to the widest number, line wrap, jump buttons, theme-aware scrollbars) - Extend mobile preview links (editor, dark and light paste views) - Clarify PALETTE_MAX_ITEM covers can items and file attachments - Document PALETTE_DEFAULT_DARK in docker-compose.yml so the compose file really covers every env var --- README.md | 5 +++-- docker-compose.yml | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 342092c..3372d3e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ a web UI for sharing text and small files. - Cookie based saved pastes and settings - Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant - Dark mode toggle in the topbar and settings, with a configurable default +- Polished code viewer: line-number gutter sized to the widest number and pinned during horizontal scroll, optional line wrap, jump-to-top/bottom buttons, and theme-aware scrollbars ## Screenshots @@ -30,7 +31,7 @@ a web UI for sharing text and small files. | ![Paste view in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-midnight-dark.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) | | ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Editor at mobile width](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-editor-new.png) | -Mobile previews (375x812): [paste view](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png). +Mobile previews (375x812): [editor](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-editor-new.png), [paste view (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [paste view (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-pastel-peach-light.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png). ## Get Started @@ -66,7 +67,7 @@ go build -o palette ./cmd/palette | `PALETTE_ADDR` | `:8080` | Listen address | | `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | -| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) | +| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) | | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `/admin-key` (0600) | | `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. | | `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. | diff --git a/docker-compose.yml b/docker-compose.yml index 068f90a..65bfb8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,10 +32,16 @@ services: # Default: 5242880 (5 MiB). # PALETTE_MAX_TEXT: "5242880" - # Max size in bytes of a single can item (file/text inside a can). + # Max size in bytes of a single can item (file/text inside a can) or a + # paste file attachment. # Default: 26214400 (25 MiB). # PALETTE_MAX_ITEM: "26214400" + # Default dark mode for new visitors. Unset = dark on; set to "false", + # "0" or "off" to default to light mode. Visitors who toggle dark mode + # keep their choice in their browser. + # PALETTE_DEFAULT_DARK: "false" + # HMAC secret for password-unlock cookies. Default: random per start, # which logs out every unlocked browser session on restart. Set a fixed # secret (any random string) to keep unlock sessions across restarts, From f63efc6d888e616959272a8c99e825925196ce38 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 20:29:40 -0500 Subject: [PATCH 15/17] Fix rate limiter bypass via client-controlled X-Forwarded-For (#280) clientIP() keyed rate-limit buckets on the rightmost X-Forwarded-For entry, assuming traefik appends the real client IP. The deployed ingress does not rewrite XFF, so rotating the header gave a fresh bucket per request (pentest H1: 8 creates with rotating XFF -> 6x201). Now the bucket keys on the actual peer address (RemoteAddr) by default; every client-supplied IP header is ignored. Deployments whose ingress overwrites a client-IP header can opt in via PALETTE_TRUSTED_IP_HEADER (e.g. CF-Connecting-IP behind Cloudflare) to restore per-client limits. Adds tests: rotating XFF no longer resets the bucket; the trusted header is honored only when explicitly configured. --- README.md | 1 + internal/api/clientip.go | 57 ++++++++++++++++ internal/api/ratelimit.go | 30 --------- internal/api/ratelimit_xff_test.go | 104 +++++++++++------------------ internal/api/server.go | 5 ++ 5 files changed, 101 insertions(+), 96 deletions(-) create mode 100644 internal/api/clientip.go diff --git a/README.md b/README.md index 342092c..43f12d7 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ go build -o palette ./cmd/palette | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `/admin-key` (0600) | | `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. | | `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. | +| `PALETTE_TRUSTED_IP_HEADER` | unset | Name of a proxy-controlled client-IP header to key API rate limits on (e.g. `CF-Connecting-IP` when Cloudflare is the ingress; Cloudflare strips any client-supplied value). Unset: rate limits key on the peer address only, and all client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored. (#280) | An `/admin` page exists for runtime settings, protected by a key set at install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See diff --git a/internal/api/clientip.go b/internal/api/clientip.go new file mode 100644 index 0000000..82ae13b --- /dev/null +++ b/internal/api/clientip.go @@ -0,0 +1,57 @@ +// clientIP extracts the client IP for rate-limit keying. +// +// Trust boundary (issue #280): the bucket key MUST NOT come from any header a +// client can influence. The previous rightmost-X-Forwarded-For scheme (#85) +// assumed Traefik appends the real client IP, but the deployed ingress does +// not rewrite XFF, so a client rotating its own XFF value got a fresh bucket +// per request and the limit was unenforceable (pentest H1: 6x201 across 8 +// rotating-XFF creates). +// +// Default: key on the actual peer address (RemoteAddr) only. Behind any +// reverse proxy this is the proxy's address, so all clients share one bucket +// per endpoint — coarse, but safe. +// +// Proxy-honoring mode: a deployment in front of a proxy that OVERWRITES (not +// appends to) a client-IP header can set PALETTE_TRUSTED_IP_HEADER (e.g. +// CF-Connecting-IP when Cloudflare is the ingress; Cloudflare strips any +// client-supplied value). The header is honored ONLY when explicitly +// configured at startup, and X-Forwarded-For / X-Real-Ip are never trusted. +package api + +import ( + "net" + "net/http" + "sync" +) + +var ( + trustedIPMu sync.RWMutex + trustedIPHeader string // empty = never trust any client-IP header +) + +// SetTrustedIPHeader configures the single proxy-controlled header whose +// value may key rate-limit buckets. Called at startup; tests may reset it. +func SetTrustedIPHeader(name string) { + trustedIPMu.Lock() + defer trustedIPMu.Unlock() + trustedIPHeader = name +} + +func getTrustedIPHeader() string { + trustedIPMu.RLock() + defer trustedIPMu.RUnlock() + return trustedIPHeader +} + +func clientIP(r *http.Request) string { + if name := getTrustedIPHeader(); name != "" { + if v := r.Header.Get(name); v != "" { + return v + } + } + host := r.RemoteAddr + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + host = h + } + return host +} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 7c44bd6..b01f9a2 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -3,7 +3,6 @@ package api import ( "net/http" "strconv" - "strings" "sync" "time" ) @@ -48,35 +47,6 @@ func (l *limiter) allow(key string, rate, burst float64) bool { return true } -// clientIP extracts the client IP for rate-limit keying (#85). -// -// Trust boundary: palette runs behind exactly ONE trusted reverse proxy -// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to -// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted -// proxy observed and is unspoofable by the client (a client-supplied fake -// entry only lands on the LEFT and is ignored). This matches chi's -// middleware.RealIP semantics for a single trusted proxy hop. -// -// Direct connections (no XFF header) fall back to RemoteAddr. Directly -// reachable deployments must NOT expose the app to untrusted networks -// without a proxy in front, or attackers could forge the rightmost entry. -func clientIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if i := strings.LastIndex(xff, ","); i >= 0 { - return strings.TrimSpace(xff[i+1:]) - } - return strings.TrimSpace(xff) - } - if xr := r.Header.Get("X-Real-Ip"); xr != "" { - return strings.TrimSpace(xr) - } - host := r.RemoteAddr - if i := strings.LastIndex(host, ":"); i > 0 { - host = host[:i] - } - return host -} - var globalLimiter = newLimiter() // globalSettingsFn is set at startup; tests can point it at fixed settings. diff --git a/internal/api/ratelimit_xff_test.go b/internal/api/ratelimit_xff_test.go index 8674636..680d5d0 100644 --- a/internal/api/ratelimit_xff_test.go +++ b/internal/api/ratelimit_xff_test.go @@ -1,85 +1,57 @@ package api -// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry -// (appended by the trusted Traefik proxy), never the raw/leftmost header -// value a client can forge. A spoofed FIRST XFF entry must not bypass the -// limit or rotate buckets. - import ( - "bytes" + "fmt" "net/http/httptest" "testing" ) -func TestClientIPTakesRightmostXFF(t *testing.T) { - r := httptest.NewRequest("POST", "/", nil) - r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod +func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { + SetTrustedIPHeader("") + defer SetTrustedIPHeader("") + r := httptest.NewRequest("POST", "/api/pastes", nil) + r.RemoteAddr = "203.0.113.7:4432" r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9") - if got := clientIP(r); got != "203.0.113.9" { - t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got) - } -} - -func TestClientIPXRealIPFallback(t *testing.T) { - r := httptest.NewRequest("POST", "/", nil) - r.RemoteAddr = "10.42.0.7:51000" r.Header.Set("X-Real-Ip", "203.0.113.10") - if got := clientIP(r); got != "203.0.113.10" { - t.Fatalf("clientIP = %q, want 203.0.113.10", got) + if got := clientIP(r); got != "203.0.113.7" { + t.Fatalf("clientIP = %q, want peer 203.0.113.7", got) } } -func TestClientIPDirectFallback(t *testing.T) { - r := httptest.NewRequest("POST", "/", nil) - r.RemoteAddr = "198.51.100.5:51000" - if got := clientIP(r); got != "198.51.100.5" { - t.Fatalf("clientIP = %q, want 198.51.100.5", got) +func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) { + SetTrustedIPHeader("") + defer SetTrustedIPHeader("") + r := httptest.NewRequest("POST", "/api/pastes", nil) + r.RemoteAddr = "10.0.1.47:9999" + r.Header.Set("CF-Connecting-IP", "198.51.100.9") + if got := clientIP(r); got != "10.0.1.47" { + t.Fatalf("unconfigured: clientIP = %q, want peer 10.0.1.47", got) + } + SetTrustedIPHeader("CF-Connecting-IP") + if got := clientIP(r); got != "198.51.100.9" { + t.Fatalf("configured: clientIP = %q, want CF-Connecting-IP value", got) } } -// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake -// leftmost XFF entry stays limited on their real (rightmost) IP. -func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) { - srv := newTestServer(t) - h := srv.routes() - for i := 0; i < 5; i++ { - req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) - req.RemoteAddr = "10.42.0.7:51000" - // each request spoofs a DIFFERENT leftmost entry - req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 201 { - t.Fatalf("req %d: want 201, got %d", i, rr.Code) +// Issue #280: rotating X-Forwarded-For must NOT reset the bucket. Pentest +// repro was 8 creates with rotating XFF -> 6x201. +func TestRotatingXFFDoesNotResetBucket(t *testing.T) { + globalLimiter = newLimiter() + defer SetTrustedIPHeader("") + SetTrustedIPHeader("") + s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway + var allowed, limited int + for i := 0; i < 8; i++ { + r := httptest.NewRequest("POST", "/api/pastes", nil) + r.RemoteAddr = "198.51.100.1:5000" + r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i)) + if rateLimitCreate(r, s) { + allowed++ + } else { + limited++ } } - // 6th request, still the same real IP, new spoofed prefix: must 429 - req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) - req.RemoteAddr = "10.42.0.7:51000" - req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 429 { - t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code) - } -} - -func spoofN(i int) string { - return "1.2.3." + string(rune('0'+i)) -} - -// Distinct real IPs must still get distinct buckets (no over-limiting). -func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) { - srv := newTestServer(t) - h := srv.routes() - for _, ip := range []string{"203.0.113.20", "203.0.113.21"} { - req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) - req.RemoteAddr = "10.42.0.7:51000" - req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 201 { - t.Fatalf("ip %s: want 201, got %d", ip, rr.Code) - } + if float64(allowed) != s.RateLimitBurst || limited != 8-int(s.RateLimitBurst) { + t.Fatalf("rotating XFF: allowed=%d limited=%d, want allowed=%v (burst), limited=%d", allowed, limited, s.RateLimitBurst, 8-int(s.RateLimitBurst)) } } diff --git a/internal/api/server.go b/internal/api/server.go index b36a714..6a61094 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,6 +28,10 @@ type Config struct { DBPath string MaxTextBytes int64 MaxItemBytes int64 + // TrustedIPHeader optionally names a proxy-controlled client-IP header + // (e.g. CF-Connecting-IP behind Cloudflare) to key rate limits on. Empty + // (default) keys on the peer address only. See clientip.go (#280). + TrustedIPHeader string } type apiServer struct { @@ -39,6 +43,7 @@ type apiServer struct { } func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer { + SetTrustedIPHeader(cfg.TrustedIPHeader) // #280 return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey} } From 3460d54fceb111600c7726651c5367c88a538649 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 20:30:04 -0500 Subject: [PATCH 16/17] Fix #282: re-evaluate jumpnav visibility after layout settles on load The initial refresh() ran before the mobile layout settled (media queries, fonts, async highlighting) and under-measured the content, leaving #jumpnav hidden on long pastes at 375x812 until a resize event. Re-check after a double rAF, on window load, after 300ms, and via a ResizeObserver on document.body for late content growth. Editor textarea scroller unchanged. --- internal/web/static/jump.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/web/static/jump.js b/internal/web/static/jump.js index 03fb611..cb1574b 100644 --- a/internal/web/static/jump.js +++ b/internal/web/static/jump.js @@ -38,4 +38,14 @@ window.addEventListener('resize', refresh); if (scroller !== window && scroller) scroller.addEventListener('input', refresh); refresh(); + /* #282: the first evaluation can run before the layout settles (media + queries, web fonts, async highlighting) and under-measure the content, + leaving the nav hidden on long pages. Re-check once a real layout exists + and after load; the ResizeObserver also catches late content growth. */ + requestAnimationFrame(function () { requestAnimationFrame(refresh); }); + window.addEventListener('load', refresh); + window.setTimeout(refresh, 300); + if (window.ResizeObserver && scroller === window && document.body) { + new ResizeObserver(refresh).observe(document.body); + } })(); From 8901a3c82c162611e981c254f85e6079d7b9fccc Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 20:31:57 -0500 Subject: [PATCH 17/17] #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)
Paste Type