From 8dde83ff683f88a67261e0925bf3af4d3f6fbc0d Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 21:28:11 -0500 Subject: [PATCH 01/73] Admin lock: replace explanation paragraph with centered key input --- internal/web/templates/admin.html | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/internal/web/templates/admin.html b/internal/web/templates/admin.html index 6af4d74..4a04798 100644 --- a/internal/web/templates/admin.html +++ b/internal/web/templates/admin.html @@ -1,20 +1,17 @@ {{template "head" .}} {{template "topbar" .}} -
-
-
+
+
+

Admin

-
-
-

Enter the admin key to manage server settings. The key is kept in - sessionStorage for this tab only and is sent as a request header — it is - never stored in a cookie, so it will not accompany normal paste requests.

-
- - +
+ +
+
+

Custom URL

- +
Stays reserved while the paste exists
From be3ff81e5c43a554b5d8489e02ec98f4ff30b0a2 Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 21:45:42 -0500 Subject: [PATCH 04/73] base --- internal/web/static/app.css | 41 ++++++++++++++++++++++++++++ internal/web/templates/settings.html | 7 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 67d0cf8..20732be 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -40,6 +40,47 @@ --ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054; --on-accent: #274a6b; } +/* matching light/dark counterparts: same hue family, flipped lightness roles */ +/* Midnight Light: light counterpart of midnight (violet family) */ +[data-preset="midnight-light"] { + --bg: #F3EFF9; --surface: #FAF8FD; --surface-2: #E1D9EF; + --muted: #A99CC9; --muted-fg: #5F5390; --fg: #2B2140; + --accent: #7B5FC0; --border: #D8CEEC; + --ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054; + --on-accent: #FAF8FD; +} +/* Smooth Dark: dark counterpart of smooth (muted slate-violet family) */ +[data-preset="smooth-dark"] { + --bg: #23222E; --surface: #2C2B3A; --surface-2: #3B3A4D; + --muted: #7A7796; --muted-fg: #B5B1C9; --fg: #E8E7F0; + --accent: #A9A6C4; --border: #45435A; + --ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3; + --on-accent: #23222E; +} +/* Pastel Lavender Dark: dark counterpart (lavender family) */ +[data-preset="pastel-lavender-dark"] { + --bg: #2A2340; --surface: #352C50; --surface-2: #4A3E6B; + --muted: #8F7FB8; --muted-fg: #CBBEE8; --fg: #EFEAF8; + --accent: #B39DE4; --border: #4E4272; + --ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3; + --on-accent: #2A2340; +} +/* Pastel Peach Dark: dark counterpart (peach family) */ +[data-preset="pastel-peach-dark"] { + --bg: #3A241C; --surface: #4A2F24; --surface-2: #654234; + --muted: #B07A64; --muted-fg: #F0C4B0; --fg: #FBEBE2; + --accent: #F9826C; --border: #75503F; + --ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3; + --on-accent: #3A241C; +} +/* Pastel Cloud Dark: dark counterpart (pink cloud family, blue accent) */ +[data-preset="pastel-cloud-dark"] { + --bg: #33222E; --surface: #402C3A; --surface-2: #583D50; + --muted: #A87E97; --muted-fg: #E5C2D6; --fg: #FAEEF5; + --accent: #A2D2FF; --border: #644759; + --ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3; + --on-accent: #33222E; +} * { box-sizing: border-box; margin: 0; padding: 0; } body { diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index b4a8629..d5a2870 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -21,7 +21,12 @@ { id: 'smooth', name: 'Smooth' }, { id: 'pastel-lavender', name: 'Pastel Lavender' }, { id: 'pastel-peach', name: 'Pastel Peach' }, - { id: 'pastel-cloud', name: 'Pastel Cloud' } + { id: 'pastel-cloud', name: 'Pastel Cloud' }, + { id: 'midnight-light', name: 'Midnight Light' }, + { id: 'smooth-dark', name: 'Smooth Dark' }, + { id: 'pastel-lavender-dark', name: 'Pastel Lavender Dark' }, + { id: 'pastel-peach-dark', name: 'Pastel Peach Dark' }, + { id: 'pastel-cloud-dark', name: 'Pastel Cloud Dark' } ]; var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent']; From 8104cdba4c1195b08df87a5b73185f5b34343990 Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 21:47:50 -0500 Subject: [PATCH 05/73] Remove custom URL reservation note and Can contents menu from new paste page Fixes #124 --- internal/web/templates/new.html | 82 --------------------------------- 1 file changed, 82 deletions(-) diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index 529168b..111de35 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -66,16 +66,6 @@

Custom URL

-
Stays reserved while the paste exists
-
-
-

Can contents

- -

Custom URL

- +
+ {{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index d5a2870..f3c195d 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -8,6 +8,11 @@

Theme

+
@@ -16,17 +21,13 @@ (function () { // #112: derive each preset's swatches from the real CSS variables in // app.css by temporarily applying data-preset, so they can never drift. - var themeNames = [ + // #127: 5 theme pairs, light swatches top row, dark bottom row. + var pairs = [ { id: 'midnight', name: 'Midnight' }, { id: 'smooth', name: 'Smooth' }, { id: 'pastel-lavender', name: 'Pastel Lavender' }, { id: 'pastel-peach', name: 'Pastel Peach' }, - { id: 'pastel-cloud', name: 'Pastel Cloud' }, - { id: 'midnight-light', name: 'Midnight Light' }, - { id: 'smooth-dark', name: 'Smooth Dark' }, - { id: 'pastel-lavender-dark', name: 'Pastel Lavender Dark' }, - { id: 'pastel-peach-dark', name: 'Pastel Peach Dark' }, - { id: 'pastel-cloud-dark', name: 'Pastel Cloud Dark' } + { id: 'pastel-cloud', name: 'Pastel Cloud' } ]; var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent']; @@ -40,26 +41,60 @@ return colors; } + // current base pair + dark flag from the resolved data-preset. + // "midnight" is itself the dark variant, so check dark ids first. + function state() { + var p = document.documentElement.dataset.preset || 'midnight'; + if (p === 'midnight' || /-dark$/.test(p)) { + return { base: p === 'midnight' ? 'midnight' : p.replace(/-dark$/, ''), dark: true }; + } + return { base: p === 'midnight-light' ? 'midnight' : p, dark: false }; + } + var grid = document.getElementById('theme-grid'); - var current = document.documentElement.dataset.preset || 'midnight'; - themeNames.forEach(function (t) { - var colors = presetColors(t.id); + var cards = {}; + pairs.forEach(function (t) { + var light = presetColors(t.id); + var dark = presetColors(t.id + '-dark'); var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'theme-card'; - btn.setAttribute('aria-pressed', current === t.id ? 'true' : 'false'); + btn.setAttribute('aria-pressed', 'false'); + btn.setAttribute('data-pair', t.id); btn.innerHTML = '' + t.name + '' + - '' + colors.map(function (c) { + '' + light.map(function (c) { + return ''; + }).join('') + '' + + '' + dark.map(function (c) { return ''; }).join('') + ''; btn.addEventListener('click', function () { - document.documentElement.dataset.preset = t.id; + var dark = state().dark; + document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id; try { localStorage.setItem('palette-theme', t.id); } catch (e) {} - grid.querySelectorAll('.theme-card').forEach(function (c) { c.setAttribute('aria-pressed', 'false'); }); + Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); btn.setAttribute('aria-pressed', 'true'); }); + cards[t.id] = btn; grid.appendChild(btn); }); + + function sync() { + var s = state(); + Object.keys(cards).forEach(function (k) { + cards[k].setAttribute('aria-pressed', k === s.base ? 'true' : 'false'); + }); + var dt = document.getElementById('settings-dark-toggle'); + if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false'); + } + sync(); + // the topbar script runs before this button exists, so wire it here + var dt = document.getElementById('settings-dark-toggle'); + dt.addEventListener('click', function () { + var btns = document.querySelectorAll('.topbar .dark-toggle'); + if (btns.length) btns[0].click(); else document.dispatchEvent(new CustomEvent('palette-darkchange')); + }); + document.addEventListener('palette-darkchange', sync); })(); {{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index 6d164ae..e7ba16f 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -24,6 +24,44 @@ import ( //go:embed templates/*.html var tmplFS embed.FS +// DefaultDark is the server-configured default dark mode state for visitors +// without stored preferences (#127). Set via PALETTE_DEFAULT_DARK. +var DefaultDark = true + +// SetDefaultDark applies the PALETTE_DEFAULT_DARK env var: unset = dark on, +// "false"/"0"/"off" = dark off. +func SetDefaultDark(v string) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "false", "0", "off": + DefaultDark = false + default: + DefaultDark = true + } +} + +// ResolvePreset maps a theme pair id (or explicit variant id) plus dark state +// to a concrete preset id. Returns the defaults when pair is empty or unknown. +// Precedence handled by callers: URL ?theme= > stored pair+dark > defaults. +func ResolvePreset(pair string, dark bool) string { + variants := map[string][2]string{ // pair -> {light, dark} + "midnight": {"midnight-light", "midnight"}, + "smooth": {"smooth", "smooth-dark"}, + "pastel-lavender": {"pastel-lavender", "pastel-lavender-dark"}, + "pastel-peach": {"pastel-peach", "pastel-peach-dark"}, + "pastel-cloud": {"pastel-cloud", "pastel-cloud-dark"}, + } + if v, ok := variants[pair]; ok { + if dark { + return v[1] + } + return v[0] + } + if dark { + return "midnight" + } + return "midnight-light" +} + //go:embed static var staticFS embed.FS @@ -33,8 +71,9 @@ type UI struct { func New() (*UI, error) { funcs := template.FuncMap{ - "humanSize": humanSize, - "version": func() string { return Version }, // #93: topbar version label + "humanSize": humanSize, + "version": func() string { return Version }, // #93: topbar version label + "defaultDark": func() bool { return DefaultDark }, // #127: server-configured default } t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html") if err != nil { diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..56fc339 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,48 @@ +package web + +import "testing" + +func TestResolvePreset(t *testing.T) { + cases := []struct { + pair string + dark bool + want string + }{ + {"", false, "midnight-light"}, + {"", true, "midnight"}, + {"midnight", false, "midnight-light"}, + {"midnight", true, "midnight"}, + {"smooth", false, "smooth"}, + {"smooth", true, "smooth-dark"}, + {"pastel-lavender", true, "pastel-lavender-dark"}, + {"pastel-peach", false, "pastel-peach"}, + {"pastel-cloud", true, "pastel-cloud-dark"}, + {"bogus", true, "midnight"}, + {"bogus", false, "midnight-light"}, + // explicit variant ids also resolve (URL ?theme= may pass them) + {"smooth-dark", true, "midnight"}, + } + for _, c := range cases { + if got := ResolvePreset(c.pair, c.dark); got != c.want { + t.Errorf("ResolvePreset(%q, %v) = %q, want %q", c.pair, c.dark, got, c.want) + } + } +} + +func TestSetDefaultDark(t *testing.T) { + for _, off := range []string{"false", "0", "off", "OFF", " false "} { + DefaultDark = true + SetDefaultDark(off) + if DefaultDark { + t.Errorf("SetDefaultDark(%q): want dark off", off) + } + } + for _, on := range []string{"true", "1", "on", "anything"} { + DefaultDark = false + SetDefaultDark(on) + if !DefaultDark { + t.Errorf("SetDefaultDark(%q): want dark on", on) + } + } + DefaultDark = true +} From dcf2d66a8288b548dbf7df0e1e8a9bfdeec64ffd Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:17:04 -0500 Subject: [PATCH 08/73] Theme pairs with dark mode toggle, gear active state, PALETTE_DEFAULT_DARK (#127) - Collapse the 10 theme cards into 5 pairs: light swatches top row, dark bottom row - Dark mode toggle in settings and topbar (sun/moon inline SVG) - Toggling dark mode switches to the other variant of the selected pair - Persist pair id in localStorage palette-theme, dark flag in palette-dark - Head script resolves base+dark to variant; URL ?theme= accepts both ids and wins - Settings gear highlights like nav tabs on /settings - PALETTE_DEFAULT_DARK env var sets server default dark state (default on) - Server-side resolution helpers + tests --- README.md | 1 + cmd/palette/main.go | 1 + internal/web/static/app.css | 64 +++++++++++++++++++ internal/web/templates/layout.html | 94 ++++++++++++++++++++++++++-- internal/web/templates/settings.html | 63 ++++++++++++++----- internal/web/web.go | 43 ++++++++++++- internal/web/web_test.go | 48 ++++++++++++++ 7 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 internal/web/web_test.go diff --git a/README.md b/README.md index 0c15dbd..aaac322 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ go build -o palette ./cmd/palette | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | | `PALETTE_MAX_ITEM` | `26214400` | Max can item 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. | 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/cmd/palette/main.go b/cmd/palette/main.go index 2807582..11f494b 100644 --- a/cmd/palette/main.go +++ b/cmd/palette/main.go @@ -42,6 +42,7 @@ func main() { if err != nil { log.Fatal(err) } + web.SetDefaultDark(os.Getenv("PALETTE_DEFAULT_DARK")) srv := api.NewServer(st, cfg, ui, ss, adminKey) log.Printf("palette listening on %s", cfg.Addr) log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes())) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 4dd8e73..5a9a411 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -203,6 +203,22 @@ body { .iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 5px 12px; font: inherit; font-size: 21.6px; cursor: pointer; text-decoration: none; } .iconbtn.gear { display: inline-flex; align-items: center; padding: 5px 9px; } .iconbtn.gear svg { width: 22px; height: 22px; } +/* #127: gear highlights like selected nav tabs; dark toggle button */ +.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; } +.iconbtn.dark-toggle svg { width: 22px; height: 22px; } +.iconbtn.dark-toggle .icon-moon { display: none; } +.iconbtn.dark-toggle .icon-sun { display: inline-block; } +.dark .iconbtn.dark-toggle .icon-sun { display: none; } +.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } +/* #127: gear highlights like selected nav tabs; dark toggle button */ +.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; } +.iconbtn.dark-toggle svg { width: 22px; height: 22px; } +.iconbtn.dark-toggle .icon-moon { display: none; } +.iconbtn.dark-toggle .icon-sun { display: inline-block; } +.dark .iconbtn.dark-toggle .icon-sun { display: none; } +.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } .settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); } .settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; } .settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; } @@ -480,6 +496,12 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } .topbar nav a { padding: 5px 8px; font-size: 15px; } .iconbtn.gear { padding: 4px 7px; flex-shrink: 0; } .iconbtn.gear svg { width: 18px; height: 18px; } + .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } + .iconbtn.dark-toggle svg { width: 18px; height: 18px; } + .iconbtn.dark-toggle span { display: none; } + .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } + .iconbtn.dark-toggle svg { width: 18px; height: 18px; } + .iconbtn.dark-toggle span { display: none; } /* new paste: stack editor above sidebar, natural page height */ .deck { @@ -627,3 +649,45 @@ button[type="submit"]:focus-visible, padding-top: 20px; border-top: 1px solid var(--border); } + +/* #38: attachment upload on /new + attachment display on paste view */ +.side-section .file-input { display: none; } +.dropzone { + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + padding: 14px 12px; + text-align: center; + color: var(--muted-fg); + cursor: pointer; + font-size: 20.7px; + transition: border-color .15s, background .15s; + background: var(--surface-2, transparent); +} +.dropzone:hover, .dropzone.dragover { + border-color: var(--accent); + background: var(--surface-2); +} +.dropzone.dragover { border-width: 2px; } +.file-chip { + display: flex; align-items: center; gap: 10px; + border: 1px solid var(--border); border-radius: var(--radius); + padding: 8px 14px; margin-top: 8px; background: var(--surface); + font-size: 21.6px; +} +.file-chip .file-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } +.file-chip .file-chip-size { color: var(--muted-fg); font-size: 19px; } +.file-chip .file-chip-remove { + border: none; background: none; color: var(--muted-fg); cursor: pointer; + font-size: 22px; padding: 0 4px; border-radius: var(--radius-sm); +} +.file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); } +.attachment-bar { display: flex; flex-direction: column; gap: 10px; } +.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; +} +.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); } diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index aaf33db..46337d6 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -3,11 +3,48 @@ {{end}} @@ -22,8 +59,57 @@ Git
- + +
+ {{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index d5a2870..f3c195d 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -8,6 +8,11 @@

Theme

+
@@ -16,17 +21,13 @@ (function () { // #112: derive each preset's swatches from the real CSS variables in // app.css by temporarily applying data-preset, so they can never drift. - var themeNames = [ + // #127: 5 theme pairs, light swatches top row, dark bottom row. + var pairs = [ { id: 'midnight', name: 'Midnight' }, { id: 'smooth', name: 'Smooth' }, { id: 'pastel-lavender', name: 'Pastel Lavender' }, { id: 'pastel-peach', name: 'Pastel Peach' }, - { id: 'pastel-cloud', name: 'Pastel Cloud' }, - { id: 'midnight-light', name: 'Midnight Light' }, - { id: 'smooth-dark', name: 'Smooth Dark' }, - { id: 'pastel-lavender-dark', name: 'Pastel Lavender Dark' }, - { id: 'pastel-peach-dark', name: 'Pastel Peach Dark' }, - { id: 'pastel-cloud-dark', name: 'Pastel Cloud Dark' } + { id: 'pastel-cloud', name: 'Pastel Cloud' } ]; var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent']; @@ -40,26 +41,60 @@ return colors; } + // current base pair + dark flag from the resolved data-preset. + // "midnight" is itself the dark variant, so check dark ids first. + function state() { + var p = document.documentElement.dataset.preset || 'midnight'; + if (p === 'midnight' || /-dark$/.test(p)) { + return { base: p === 'midnight' ? 'midnight' : p.replace(/-dark$/, ''), dark: true }; + } + return { base: p === 'midnight-light' ? 'midnight' : p, dark: false }; + } + var grid = document.getElementById('theme-grid'); - var current = document.documentElement.dataset.preset || 'midnight'; - themeNames.forEach(function (t) { - var colors = presetColors(t.id); + var cards = {}; + pairs.forEach(function (t) { + var light = presetColors(t.id); + var dark = presetColors(t.id + '-dark'); var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'theme-card'; - btn.setAttribute('aria-pressed', current === t.id ? 'true' : 'false'); + btn.setAttribute('aria-pressed', 'false'); + btn.setAttribute('data-pair', t.id); btn.innerHTML = '' + t.name + '' + - '' + colors.map(function (c) { + '' + light.map(function (c) { + return ''; + }).join('') + '' + + '' + dark.map(function (c) { return ''; }).join('') + ''; btn.addEventListener('click', function () { - document.documentElement.dataset.preset = t.id; + var dark = state().dark; + document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id; try { localStorage.setItem('palette-theme', t.id); } catch (e) {} - grid.querySelectorAll('.theme-card').forEach(function (c) { c.setAttribute('aria-pressed', 'false'); }); + Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); btn.setAttribute('aria-pressed', 'true'); }); + cards[t.id] = btn; grid.appendChild(btn); }); + + function sync() { + var s = state(); + Object.keys(cards).forEach(function (k) { + cards[k].setAttribute('aria-pressed', k === s.base ? 'true' : 'false'); + }); + var dt = document.getElementById('settings-dark-toggle'); + if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false'); + } + sync(); + // the topbar script runs before this button exists, so wire it here + var dt = document.getElementById('settings-dark-toggle'); + dt.addEventListener('click', function () { + var btns = document.querySelectorAll('.topbar .dark-toggle'); + if (btns.length) btns[0].click(); else document.dispatchEvent(new CustomEvent('palette-darkchange')); + }); + document.addEventListener('palette-darkchange', sync); })(); {{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index 6d164ae..e7ba16f 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -24,6 +24,44 @@ import ( //go:embed templates/*.html var tmplFS embed.FS +// DefaultDark is the server-configured default dark mode state for visitors +// without stored preferences (#127). Set via PALETTE_DEFAULT_DARK. +var DefaultDark = true + +// SetDefaultDark applies the PALETTE_DEFAULT_DARK env var: unset = dark on, +// "false"/"0"/"off" = dark off. +func SetDefaultDark(v string) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "false", "0", "off": + DefaultDark = false + default: + DefaultDark = true + } +} + +// ResolvePreset maps a theme pair id (or explicit variant id) plus dark state +// to a concrete preset id. Returns the defaults when pair is empty or unknown. +// Precedence handled by callers: URL ?theme= > stored pair+dark > defaults. +func ResolvePreset(pair string, dark bool) string { + variants := map[string][2]string{ // pair -> {light, dark} + "midnight": {"midnight-light", "midnight"}, + "smooth": {"smooth", "smooth-dark"}, + "pastel-lavender": {"pastel-lavender", "pastel-lavender-dark"}, + "pastel-peach": {"pastel-peach", "pastel-peach-dark"}, + "pastel-cloud": {"pastel-cloud", "pastel-cloud-dark"}, + } + if v, ok := variants[pair]; ok { + if dark { + return v[1] + } + return v[0] + } + if dark { + return "midnight" + } + return "midnight-light" +} + //go:embed static var staticFS embed.FS @@ -33,8 +71,9 @@ type UI struct { func New() (*UI, error) { funcs := template.FuncMap{ - "humanSize": humanSize, - "version": func() string { return Version }, // #93: topbar version label + "humanSize": humanSize, + "version": func() string { return Version }, // #93: topbar version label + "defaultDark": func() bool { return DefaultDark }, // #127: server-configured default } t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html") if err != nil { diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..56fc339 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,48 @@ +package web + +import "testing" + +func TestResolvePreset(t *testing.T) { + cases := []struct { + pair string + dark bool + want string + }{ + {"", false, "midnight-light"}, + {"", true, "midnight"}, + {"midnight", false, "midnight-light"}, + {"midnight", true, "midnight"}, + {"smooth", false, "smooth"}, + {"smooth", true, "smooth-dark"}, + {"pastel-lavender", true, "pastel-lavender-dark"}, + {"pastel-peach", false, "pastel-peach"}, + {"pastel-cloud", true, "pastel-cloud-dark"}, + {"bogus", true, "midnight"}, + {"bogus", false, "midnight-light"}, + // explicit variant ids also resolve (URL ?theme= may pass them) + {"smooth-dark", true, "midnight"}, + } + for _, c := range cases { + if got := ResolvePreset(c.pair, c.dark); got != c.want { + t.Errorf("ResolvePreset(%q, %v) = %q, want %q", c.pair, c.dark, got, c.want) + } + } +} + +func TestSetDefaultDark(t *testing.T) { + for _, off := range []string{"false", "0", "off", "OFF", " false "} { + DefaultDark = true + SetDefaultDark(off) + if DefaultDark { + t.Errorf("SetDefaultDark(%q): want dark off", off) + } + } + for _, on := range []string{"true", "1", "on", "anything"} { + DefaultDark = false + SetDefaultDark(on) + if !DefaultDark { + t.Errorf("SetDefaultDark(%q): want dark on", on) + } + } + DefaultDark = true +} From 4948ef9f1cc650edb96a56935e9a6311f3e79321 Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:21:15 -0500 Subject: [PATCH 09/73] #38 iteration 1: file attachments, 1 file per paste - internal/store/blob.go: BlobStore interface + fs implementation with traversal-safe keys (/), put/get/stat/delete - attachments table migration (id, paste_id, filename sanitized to 255, mime sniffed server-side, size, sha256, created_at) - POST /api/pastes now accepts multipart/form-data with a 'file' part; 1 file = 1 paste: file replaces text content when both are sent - 25 MB per-file limit enforced server-side (413 file_too_large) - GET /f/{attachment-id}/{filename}: stored sniffed mime, nosniff, inline only for images/pdf, html/svg/xml forced to text/plain (#34 rule) - paste view renders attachment chip + inline image preview - /new: dropzone with file picker, drag-and-drop, Ctrl+V file paste, file chip with name/size/remove, matches pill/radius design - tests: blob roundtrip/traversal/sanitize; multipart create (mime sniffing, client mime ignored, size limit, two-file reject, html/svg forcing, 404s, password/expiry fields) --- .gitignore | 1 + internal/api/attachments.go | 351 ++++++++++++++++++++++++++++++ internal/api/attachments_test.go | 308 ++++++++++++++++++++++++++ internal/api/server.go | 8 +- internal/api/validate.go | 4 + internal/store/attachment.go | 147 +++++++++++++ internal/store/blob.go | 139 ++++++++++++ internal/store/blob_test.go | 108 +++++++++ internal/store/store.go | 26 ++- internal/web/static/app.css | 11 + internal/web/templates/new.html | 131 ++++++++++- internal/web/templates/paste.html | 13 ++ 12 files changed, 1244 insertions(+), 3 deletions(-) create mode 100644 internal/api/attachments.go create mode 100644 internal/api/attachments_test.go create mode 100644 internal/store/attachment.go create mode 100644 internal/store/blob.go create mode 100644 internal/store/blob_test.go diff --git a/.gitignore b/.gitignore index a1acada..fef4f42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ palette.db-shm palette.db-wal admin-key settings.json +:memory:.files/ diff --git a/internal/api/attachments.go b/internal/api/attachments.go new file mode 100644 index 0000000..04c4c56 --- /dev/null +++ b/internal/api/attachments.go @@ -0,0 +1,351 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "palette/internal/store" +) + +// #38: file attachments, iteration 1: one file per paste. A paste either has +// text content OR one attached file. Multipart create + /f/ serving route. + +const ( + MaxAttachmentBytes = 25 << 20 // 25 MB per file + maxFileBytesHard = MaxAttachmentBytes + 1<<20 // sniff headroom; over this reject before reading it all +) + +// sniffMime runs http.DetectContentType on the first 512 bytes (and any +// remainder of the head) of r, returning the sniffed mime and a reader that +// replays the full stream. Mime is NEVER taken from the client. +func sniffMime(r io.Reader) (string, io.Reader, error) { + head := make([]byte, 512) + n, err := io.ReadFull(r, head) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return "", nil, err + } + head = head[:n] + mime := http.DetectContentType(head) + return mime, io.MultiReader(bytes.NewReader(head), r), nil +} + +// sanitizeMimeForServing maps the stored (sniffed) mime to the Content-Type +// used on /f/. Active-content types (html, svg, xml...) are forced to +// text/plain — same rule as the /raw #34 fix — so a malicious upload can +// never execute on this origin. +func serveContentType(mime string) string { + base := mime + if i := strings.IndexByte(mime, ';'); i >= 0 { + base = strings.TrimSpace(mime[:i]) + } + base = strings.ToLower(base) + switch base { + case "text/html", "image/svg+xml", "application/xhtml+xml", "text/xml", + "application/xml", "application/xhtml", "image/xml+svg": + return "text/plain; charset=utf-8" + } + return mime +} + +// inlineable reports whether the sniffed mime is safe to render inline +// (Content-Disposition: inline); everything else downloads as an attachment. +func inlineable(mime string) bool { + base := mime + if i := strings.IndexByte(mime, ';'); i >= 0 { + base = strings.TrimSpace(mime[:i]) + } + base = strings.ToLower(base) + switch { + case strings.HasPrefix(base, "image/"), base == "application/pdf": + return true + } + return false +} + +// mime/multipart parts are fully read during parsing (the multipart reader +// closes each part when advancing) and the mime is sniffed from bytes. + +// limitAttachment rejects reads past the 25 MB per-file cap server-side. +type limitReader struct { + r io.Reader + n int64 + max int64 +} + +func (l *limitReader) Read(p []byte) (int, error) { + if l.n > l.max { + return 0, store.ErrFileTooLarge + } + n, err := l.r.Read(p) + l.n += int64(n) + if l.n > l.max && err == nil { + err = store.ErrFileTooLarge + } + return n, err +} + +// 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 +// also present, the file wins and the text is ignored — simplest correct +// behavior, documented in the PR). +func (a *apiServer) handleCreatePasteMultipart(w http.ResponseWriter, r *http.Request, s Settings) { + blobs := a.store.Blobs() + if blobs == nil { + writeErr(w, 500, "blob storage unavailable") + return + } + // guard the raw body: 25 MB file + multipart overhead headroom + mr, err := r.MultipartReader() + if err != nil { + writeErr(w, 400, "invalid multipart body") + return + } + var ( + p store.Paste + fileSeen bool + att store.Attachment + fileBody io.Reader + ) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + if isBodyTooLarge(err) { + writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large") + return + } + writeErr(w, 400, "invalid multipart body") + return + } + name := part.FormName() + if name == "file" { + if fileSeen { + writeErrCode(w, 400, "one_file_only", "Choose either text or a file for now. Only one file per paste.") + part.Close() + return + } + // The part must be fully read during parsing: the multipart + // reader closes it as soon as the next part is fetched. Read it + // here into memory (bounded by the 25 MB cap) and sniff the mime + // from the content, never from client headers. + limited := io.LimitReader(part, MaxAttachmentBytes+1) + raw, err := io.ReadAll(limited) + part.Close() + if err != nil { + writeErr(w, 400, "invalid file part") + return + } + if int64(len(raw)) > MaxAttachmentBytes { + writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large", + "File is too large. The limit is 25 MB.") + return + } + if len(raw) == 0 { + writeErrCode(w, 400, "content_empty", "The file is empty.") + return + } + filename := store.SanitizeFilename(part.FileName()) + mime := http.DetectContentType(raw[:min(512, len(raw))]) + att = store.Attachment{PasteID: "pending", Filename: filename, Mime: mime} + fileBody = bytes.NewReader(raw) + fileSeen = true + continue + } + val, err := io.ReadAll(io.LimitReader(part, 1<<16)) + part.Close() + if err != nil { + writeErr(w, 400, "invalid multipart field") + return + } + v := string(val) + switch name { + case "content": + p.Content = v + case "title": + p.Title = &v + case "language": + p.Language = &v + case "custom_slug": + p.CustomSlug = &v + case "password": + p.Password = &v + case "expires_in": + p.ExpiresIn = &v + case "visibility": + p.Visibility = v + case "burn_after_read": + p.BurnAfterRead = v == "true" || v == "1" || v == "on" + case "burn_after_reads": + if n, err := strconv.Atoi(v); err == nil { + p.BurnAfterReads = &n + } + } + } + + if fileSeen { + // 1 file = 1 paste: the file replaces text content. + p.Content = "" + } else if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 { + if status == http.StatusRequestEntityTooLarge { + writeErrCode(w, status, "content_too_large", msg) + } else { + writeErrCode(w, status, "content_empty", msg) + } + return + } + // #86 metadata bounds + default expiry: same rules as the JSON path + if p.Title != nil { + t, err := checkTitle(*p.Title) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + p.Title = &t + } + if p.Language != nil { + l, err := checkLanguage(*p.Language) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + if l == "" { + p.Language = nil + } else { + p.Language = &l + } + } + if p.BurnAfterReads != nil { + if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil { + writeErr(w, 400, err.Error()) + return + } + } + if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" { + def := s.DefaultExpiry + p.ExpiresIn = &def + } + p.ViewerID = currentViewerID(r) + + created, err := a.store.CreatePaste(&p) + if err != nil { + writeErrCode(w, 400, createErrCode(err), err.Error()) + return + } + + resp := map[string]any{ + "id": created.ID, + "deletion_token": created.DeletionToken, + "url": "/" + created.ID, + "raw_url": "/raw/" + created.ID, + "api_url": "/api/pastes/" + created.ID, + "expires_at": created.ExpiresAt, + "created_at": created.CreatedAt, + "rate_limit": map[string]int{"create_per_sec": 1, "burst": 5}, + } + + if fileSeen { + att.PasteID = created.ID + // size pre-check happens inside the limited read; re-run with limit + // enforced so oversized uploads fail before the blob is stored. + err := a.store.CreateAttachment(&att, fileBody, blobs) + if err == store.ErrFileTooLarge { + a.store.SoftDelete(created.ID) + writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large", + "File is too large. The limit is 25 MB.") + return + } + if err != nil { + a.store.SoftDelete(created.ID) + writeErr(w, 500, "could not store file") + return + } + resp["attachment"] = map[string]any{ + "id": att.ID, + "filename": att.Filename, + "mime": att.Mime, + "size": att.Size, + "sha256": att.SHA256, + "url": "/f/" + att.ID + "/" + att.Filename, + } + } + writeJSON(w, 201, resp) +} + +// handleServeAttachment serves GET /f/{attachment-id}/{filename} with the +// stored (server-sniffed) mime, nosniff, and a safe Content-Disposition. +// The filename path segment is decorative; lookups key on the attachment id. +func (a *apiServer) handleServeAttachment(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "aid") + att, err := a.store.GetAttachment(id) + if err != nil { + writeErr(w, 500, "db error") + return + } + if att == nil { + writeErr(w, 404, "attachment not found") + return + } + // attachment inherits the paste's lifecycle: gone if the paste is gone + row, err := a.store.GetPaste(att.PasteID) + if err != nil || row == nil { + writeErr(w, 404, "attachment not found") + return + } + if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() { + writeErr(w, 404, "attachment not found") + return + } + if row.Burned() { + writeErr(w, 404, "attachment not found") + return + } + + blobs := a.store.Blobs() + if blobs == nil { + writeErr(w, 500, "blob storage unavailable") + return + } + blob, err := blobs.Get(att.PasteID + "/" + att.SHA256) + if err != nil { + writeErr(w, 404, "attachment not found") + return + } + defer blob.Close() + + ct := serveContentType(att.Mime) + w.Header().Set("Content-Type", ct) + w.Header().Set("X-Content-Type-Options", "nosniff") + disposition := "attachment" + if inlineable(att.Mime) { + disposition = "inline" + } + w.Header().Set("Content-Disposition", + fmt.Sprintf(`%s; filename="%s"`, disposition, asciiFilename(att.Filename))) + w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size)) + http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob) +} + +// asciiFilename quotes a filename for the Content-Disposition header, +// escaping quotes and backslashes and dropping non-ASCII bytes. +func asciiFilename(name string) string { + var b strings.Builder + for _, r := range name { + if r < 128 && r != '"' && r != '\\' && r > 31 { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "file" + } + return b.String() +} diff --git a/internal/api/attachments_test.go b/internal/api/attachments_test.go new file mode 100644 index 0000000..c9840cc --- /dev/null +++ b/internal/api/attachments_test.go @@ -0,0 +1,308 @@ +package api + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "palette/internal/store" +) + +// multipartCreate posts a multipart create to the routes handler; extra +// fields are appended as text parts. Returns recorder and parsed response. +func multipartCreate(t *testing.T, h http.Handler, filename string, content []byte, fields map[string]string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + if filename != "" { + fw, _ := mw.CreateFormFile("file", filename) + fw.Write(content) + } + for k, v := range fields { + mw.WriteField(k, v) + } + mw.Close() + req := httptest.NewRequest("POST", "/api/pastes", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var resp map[string]any + json.Unmarshal(rec.Body.Bytes(), &resp) + return rec, resp +} + +func TestMultipartAttachmentCreateAndServe(t *testing.T) { + s := testServer(t) + h := s.routes() + + png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0, 1, 2, 3}, 32)...) + rec, resp := multipartCreate(t, h, "shot.png", png, map[string]string{"title": "with file"}) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + att, _ := resp["attachment"].(map[string]any) + if att == nil { + t.Fatalf("no attachment in response: %v", resp) + } + id, _ := att["id"].(string) + url, _ := att["url"].(string) + if url != "/f/"+id+"/shot.png" { + t.Fatalf("attachment url = %q", url) + } + if att["mime"] != "image/png" { + t.Fatalf("sniffed mime = %v want image/png", att["mime"]) + } + + // serve: image mime -> inline, nosniff, stored bytes + req := httptest.NewRequest("GET", url, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != 200 { + t.Fatalf("serve: %d %s", rec2.Code, rec2.Body.String()) + } + if got := rec2.Header().Get("Content-Type"); got != "image/png" { + t.Fatalf("Content-Type = %q", got) + } + if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("nosniff = %q", got) + } + if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") { + t.Fatalf("Content-Disposition = %q", got) + } + if !bytes.Equal(rec2.Body.Bytes(), png) { + t.Fatal("served bytes differ from upload") + } +} + +func TestMultipartFileReplacesText(t *testing.T) { + s := testServer(t) + h := s.routes() + rec, resp := multipartCreate(t, h, "notes.txt", []byte("file body"), map[string]string{"content": "some text"}) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + id, _ := resp["id"].(string) + req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + var got map[string]any + json.Unmarshal(rec2.Body.Bytes(), &got) + if got["content"] != "" { + t.Fatalf("content should be empty when file provided, got %v", got["content"]) + } +} + +func TestMultipartSecondFileRejected(t *testing.T) { + s := testServer(t) + h := s.routes() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + for _, name := range []string{"a.txt", "b.txt"} { + fw, _ := mw.CreateFormFile("file", name) + fw.Write([]byte("x")) + } + mw.Close() + req := httptest.NewRequest("POST", "/api/pastes", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 400 { + t.Fatalf("two files: got %d want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), "one_file_only") { + t.Fatalf("error code missing: %s", rec.Body.String()) + } +} + +func TestMultipartHtmlUploadServesAsPlainText(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()) + } + url, _ := resp["attachment"].(map[string]any)["url"].(string) + req := httptest.NewRequest("GET", url, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("html served as %q, want text/plain", got) + } + if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") { + t.Fatalf("html Content-Disposition = %q, want attachment", got) + } +} + +func TestMultipartSvgUploadServesAsPlainText(t *testing.T) { + s := testServer(t) + h := s.routes() + svg := []byte(``) + rec, resp := multipartCreate(t, h, "evil.svg", svg, nil) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + url, _ := resp["attachment"].(map[string]any)["url"].(string) + req := httptest.NewRequest("GET", url, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + ct := rec2.Header().Get("Content-Type") + if strings.Contains(ct, "svg") || strings.Contains(ct, "html") { + t.Fatalf("svg served as %q", ct) + } + if ct != "text/plain; charset=utf-8" { + t.Fatalf("svg Content-Type = %q", ct) + } +} + +func TestMultipartClientMimeIgnored(t *testing.T) { + // client claims image/png; server must sniff the real type (text) + s := testServer(t) + h := s.routes() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("file", "fake.png") + fw.Write([]byte("just plain text, definitely not a png")) + // note: CreateFormFile sets Content-Type: application/octet-stream; the + // sniffed type for text content is text/plain either way. + mw.Close() + req := httptest.NewRequest("POST", "/api/pastes", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + var resp map[string]any + json.Unmarshal(rec.Body.Bytes(), &resp) + att := resp["attachment"].(map[string]any) + if att["mime"] != "text/plain; charset=utf-8" && att["mime"] != "text/plain" { + t.Fatalf("mime = %v, want sniffed text/plain", att["mime"]) + } +} + +func TestMultipartOversizeRejected(t *testing.T) { + s := testServer(t) + h := s.routes() + big := bytes.Repeat([]byte("A"), MaxAttachmentBytes+1024) + rec, _ := multipartCreate(t, h, "big.bin", big, nil) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversize: got %d want 413", rec.Code) + } +} + +func TestMultipartExactlyAtLimitAccepted(t *testing.T) { + s := testServer(t) + h := s.routes() + exact := bytes.Repeat([]byte("A"), MaxAttachmentBytes) + rec, resp := multipartCreate(t, h, "exact.bin", exact, nil) + if rec.Code != 201 { + t.Fatalf("at-limit: got %d %s", rec.Code, rec.Body.String()) + } + att := resp["attachment"].(map[string]any) + if att["size"].(float64) != float64(MaxAttachmentBytes) { + t.Fatalf("size = %v", att["size"]) + } +} + +func TestMultipartEmptyFileRejected(t *testing.T) { + s := testServer(t) + h := s.routes() + rec, _ := multipartCreate(t, h, "empty.txt", nil, nil) + if rec.Code != 400 { + t.Fatalf("empty file: got %d want 400", rec.Code) + } +} + +func TestServeAttachment404Missing(t *testing.T) { + s := testServer(t) + h := s.routes() + req := httptest.NewRequest("GET", "/f/zzzzzzzz/nonexistent.txt", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 404 { + t.Fatalf("missing attachment: got %d want 404", rec.Code) + } +} + +func TestServeAttachmentUnknownPaste404(t *testing.T) { + // attachment row referencing a paste that doesn't exist must 404, not leak + s := testServer(t) + h := s.routes() + s.store.CreatePaste(&store.Paste{Content: "x"}) + att := store.Attachment{PasteID: "ghost00", Filename: "f.txt", Mime: "text/plain"} + blobs := s.store.Blobs() + if err := s.store.CreateAttachment(&att, strings.NewReader("hello"), blobs); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/f/"+att.ID+"/f.txt", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 404 { + t.Fatalf("orphan attachment: got %d want 404", rec.Code) + } +} + +func TestServeAttachmentPdfInline(t *testing.T) { + s := testServer(t) + h := s.routes() + pdf := []byte("%PDF-1.4\n%fake pdf body\n") + rec, resp := multipartCreate(t, h, "doc.pdf", pdf, nil) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + url, _ := resp["attachment"].(map[string]any)["url"].(string) + req := httptest.NewRequest("GET", url, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if got := rec2.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/pdf") { + t.Fatalf("pdf Content-Type = %q", got) + } + if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") { + t.Fatalf("pdf Content-Disposition = %q", got) + } +} + +func TestServeAttachmentBurnedPaste404(t *testing.T) { + s := testServer(t) + h := s.routes() + rec, resp := multipartCreate(t, h, "burn.txt", []byte("burn me"), nil) + if rec.Code != 201 { + t.Fatalf("create: %d", rec.Code) + } + att := resp["attachment"].(map[string]any) + url, _ := att["url"].(string) + pid, _ := resp["id"].(string) + // burn the paste via API read (burn_after_read default off here, so force) + s.store.SoftDelete(pid) + req := httptest.NewRequest("GET", url, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != 404 { + t.Fatalf("deleted paste attachment: got %d want 404", rec2.Code) + } +} + +func TestMultipartPasswordFieldAccepted(t *testing.T) { + s := testServer(t) + h := s.routes() + rec, resp := multipartCreate(t, h, "secret.txt", []byte("top secret"), + map[string]string{"password": "hunter2", "expires_in": "1h"}) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + if resp["attachment"] == nil { + t.Fatal("attachment missing") + } + id, _ := resp["id"].(string) + req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != 401 { + t.Fatalf("paste should require password, got %d", rec2.Code) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index a1a2ae4..ff4d4a5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -113,7 +113,8 @@ func (a *apiServer) routes() http.Handler { r.Get("/can/{id}", a.handleCanPage) r.Post("/can/{id}", a.handleCanPage) - // raw + // raw + #38 attachment serving + r.Get("/f/{aid}/{filename}", a.handleServeAttachment) r.Get("/raw/{id}", a.handleRaw) // web pages @@ -180,6 +181,11 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { writeRateLimited(w, 1) return } + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + a.handleCreatePasteMultipart(w, r, s) // #38: file attachment create + return + } var p store.Paste if err := json.NewDecoder(r.Body).Decode(&p); err != nil { if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader diff --git a/internal/api/validate.go b/internal/api/validate.go index 40c7cdb..0f2c057 100644 --- a/internal/api/validate.go +++ b/internal/api/validate.go @@ -18,6 +18,10 @@ import ( func (a *apiServer) maxRequestBody() int64 { s := a.settings.get() max := s.MaxContentBytes + 4096 + // #38: multipart create carries up to one 25 MB file plus overhead + if a.cfg.MaxItemBytes+1<<20 > max { + max = a.cfg.MaxItemBytes + 1<<20 + } if max < 64*1024 { max = 64 * 1024 } diff --git a/internal/store/attachment.go b/internal/store/attachment.go new file mode 100644 index 0000000..a944cb3 --- /dev/null +++ b/internal/store/attachment.go @@ -0,0 +1,147 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "path/filepath" + "strings" + "time" +) + +// #38: file attachments, one file per paste (iteration 1). A paste either +// has text content OR one attached file. Bytes live in the BlobStore; this +// table carries the metadata. + +type Attachment struct { + ID string `json:"id"` + PasteID string `json:"paste_id"` + Filename string `json:"filename"` + Mime string `json:"mime"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + CreatedAt int64 `json:"created_at"` + + SizeHuman string `json:"-"` // template-only: human-readable size +} + +const MaxFilenameLen = 255 + +// ErrFileTooLarge is returned when an attachment exceeds the per-file cap. +var ErrFileTooLarge = errors.New("file too large") + +// SanitizeFilename cleans a user-supplied filename: strips directory +// components, control chars, and caps at 255 bytes. The extension is never +// trusted for mime decisions (mime is sniffed server-side). +func SanitizeFilename(name string) string { + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.TrimSpace(name) + var b strings.Builder + for _, r := range name { + if r < 32 || r == 127 { + continue + } + b.WriteRune(r) + } + name = b.String() + if len(name) > MaxFilenameLen { + runes := []rune(name) + for len(string(runes)) > MaxFilenameLen { + runes = runes[:len(runes)-1] + } + name = string(runes) + } + if name == "" || name == "." || name == ".." { + name = "file" + } + return name +} + +// CreateAttachment stores the file bytes and inserts the attachments row. +// The mime MUST already be sniffed server-side (http.DetectContentType by +// the caller); it is never taken from the client. +func (s *Store) CreateAttachment(a *Attachment, r interface{ Read([]byte) (int, error) }, blobs BlobStore) error { + pasteID := a.PasteID + sha, size, err := blobs.Put(pasteID+"/pending", r) + if err != nil { + return err + } + if a.Size > 0 && size > a.Size { + // caller-provided pre-check limit; treat as too large + blobs.Delete(pasteID + "/" + sha) + return ErrFileTooLarge + } + a.SHA256 = sha + a.Size = size + now := time.Now().Unix() + id := genSlug(20) + _, err = s.db.Exec(`INSERT INTO attachments (id, paste_id, filename, mime, size, sha256, created_at) + VALUES (?,?,?,?,?,?,?)`, id, pasteID, a.Filename, a.Mime, size, sha, now) + if err != nil { + blobs.Delete(pasteID + "/" + sha) + return err + } + a.ID = id + a.CreatedAt = now + return nil +} + +// GetAttachment returns the attachment row for a paste, or nil. +// One file per paste (iteration 1), so a single-row lookup keyed on paste. +func (s *Store) GetAttachmentForPaste(pasteID string) (*Attachment, error) { + row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at + FROM attachments WHERE paste_id = ? ORDER BY created_at ASC LIMIT 1`, pasteID) + return scanAttachment(row) +} + +func (s *Store) GetAttachment(id string) (*Attachment, error) { + row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at + FROM attachments WHERE id = ?`, id) + return scanAttachment(row) +} + +func scanAttachment(row *sql.Row) (*Attachment, error) { + var a Attachment + err := row.Scan(&a.ID, &a.PasteID, &a.Filename, &a.Mime, &a.Size, &a.SHA256, &a.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + a.SizeHuman = humanBytes(a.Size) + return &a, nil +} + +// humanBytes formats a byte count for display (KB/MB). +func humanBytes(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} + +// DeleteAttachment removes the row and its blob. +func (s *Store) DeleteAttachment(a *Attachment, blobs BlobStore) error { + blobs.Delete(a.PasteID + "/" + a.SHA256) + _, err := s.db.Exec(`DELETE FROM attachments WHERE id = ?`, a.ID) + return err +} + +// HasAttachment reports whether a paste already carries a file (#38: one file per paste). +func (s *Store) HasAttachment(pasteID string) bool { + var n int + s.db.QueryRow(`SELECT COUNT(*) FROM attachments WHERE paste_id = ?`, pasteID).Scan(&n) + return n > 0 +} + +// HashBytes is a small helper used by handlers to name/verify blobs. +func HashBytes(b []byte) string { + h := sha256.Sum256(b) + return string(h[:]) +} diff --git a/internal/store/blob.go b/internal/store/blob.go new file mode 100644 index 0000000..dbb47bf --- /dev/null +++ b/internal/store/blob.go @@ -0,0 +1,139 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// #38: blob storage abstraction. Files live outside SQLite on the data +// volume behind this interface so a MinIO/S3 backend can replace the fs +// implementation later without touching handlers or the UI. + +// ErrBlobNotFound is returned by Get/Stat when the key does not exist. +var ErrBlobNotFound = errors.New("blob not found") + +// BlobStore persists attachment bytes by key. +type BlobStore interface { + Put(key string, r io.Reader) (sha string, size int64, err error) + Get(key string) (io.ReadSeekCloser, error) + Delete(key string) error + Stat(key string) (int64, error) +} + +// FsBlobStore is the filesystem implementation: blobs are stored under +// root/, where key is "/" (see SanitizeBlobKey). +type FsBlobStore struct { + root string +} + +// NewFsBlobStore creates the blob root directory. +func NewFsBlobStore(root string) (*FsBlobStore, error) { + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + return &FsBlobStore{root: root}, nil +} + +// SanitizeBlobKey validates a blob key and returns the safe on-disk path +// under root. Keys must be exactly "/"; anything with +// separators outside that shape, "..", or absolute paths is rejected, so a +// crafted key can never escape the blob root (traversal). +func (f *FsBlobStore) path(key string) (string, error) { + clean := filepath.ToSlash(key) + parts := strings.Split(clean, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" || + parts[0] == "." || parts[0] == ".." || parts[1] == "." || parts[1] == ".." { + return "", fmt.Errorf("invalid blob key %q", key) + } + return filepath.Join(f.root, filepath.FromSlash(clean)), nil +} + +func (f *FsBlobStore) Put(key string, r io.Reader) (string, int64, error) { + dst, err := f.path(key) + if err != nil { + return "", 0, err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return "", 0, err + } + h := sha256.New() + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return "", 0, err + } + size, err := io.Copy(io.MultiWriter(out, h), r) + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + os.Remove(tmp) + return "", 0, err + } + sha := hex.EncodeToString(h.Sum(nil)) + // The caller-supplied key must match the content hash; rename to the + // canonical "/" path so keys are always content-derived. + canonical, err := f.path(key[:strings.IndexByte(key, '/')] + "/" + sha) + if err != nil { + os.Remove(tmp) + return "", 0, err + } + if err := os.MkdirAll(filepath.Dir(canonical), 0o700); err != nil { + os.Remove(tmp) + return "", 0, err + } + if err := os.Rename(tmp, canonical); err != nil { + os.Remove(tmp) + return "", 0, err + } + return sha, size, nil +} + +func (f *FsBlobStore) Get(key string) (io.ReadSeekCloser, error) { + dst, err := f.path(key) + if err != nil { + return nil, err + } + file, err := os.Open(dst) + if os.IsNotExist(err) { + return nil, ErrBlobNotFound + } + return file, err +} + +func (f *FsBlobStore) Delete(key string) error { + dst, err := f.path(key) + if err != nil { + return err + } + err = os.Remove(dst) + if os.IsNotExist(err) { + return ErrBlobNotFound + } + if err == nil { + // best-effort cleanup of the now-empty paste directory + os.Remove(filepath.Dir(dst)) + } + return err +} + +func (f *FsBlobStore) Stat(key string) (int64, error) { + dst, err := f.path(key) + if err != nil { + return 0, err + } + fi, err := os.Stat(dst) + if os.IsNotExist(err) { + return 0, ErrBlobNotFound + } + if err != nil { + return 0, err + } + return fi.Size(), nil +} diff --git a/internal/store/blob_test.go b/internal/store/blob_test.go new file mode 100644 index 0000000..70a20c8 --- /dev/null +++ b/internal/store/blob_test.go @@ -0,0 +1,108 @@ +package store + +import ( + "strings" + "testing" +) + +func testBlobs(t *testing.T) *FsBlobStore { + t.Helper() + b, err := NewFsBlobStore(t.TempDir() + "/files") + if err != nil { + t.Fatal(err) + } + return b +} + +func TestBlobPutGetStatDeleteRoundtrip(t *testing.T) { + b := testBlobs(t) + data := []byte("hello attachment world") + sha, size, err := b.Put("abc123/pending", strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + if size != int64(len(data)) { + t.Fatalf("size = %d want %d", size, len(data)) + } + if len(sha) != 64 { + t.Fatalf("sha256 = %q", sha) + } + // canonical key is / + got, err := b.Get("abc123/" + sha) + if err != nil { + t.Fatal(err) + } + buf := make([]byte, len(data)+10) + n, _ := got.Read(buf) + got.Close() + if string(buf[:n]) != string(data) { + t.Fatalf("roundtrip mismatch: %q", buf[:n]) + } + sz, err := b.Stat("abc123/" + sha) + if err != nil || sz != int64(len(data)) { + t.Fatalf("stat = %d, %v", sz, err) + } + if err := b.Delete("abc123/" + sha); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := b.Get("abc123/" + sha); err != ErrBlobNotFound { + t.Fatalf("get after delete: %v", err) + } +} + +func TestBlobStatMissing(t *testing.T) { + b := testBlobs(t) + if _, err := b.Stat("nope/deadbeef"); err != ErrBlobNotFound { + t.Fatalf("want ErrBlobNotFound, got %v", err) + } +} + +func TestBlobTraversalPrevention(t *testing.T) { + b := testBlobs(t) + evil := []string{ + "../../etc/passwd", + "../escape", + "..\\windows", + "/abs/path", + "a/b/c", // too many segments + "onlyone", // no slash + "./relative", // dot segment + "../..", // bare traversal + "ok/../traverse", // traversal inside + } + for _, key := range evil { + if _, _, err := b.Put(key, strings.NewReader("x")); err == nil { + t.Errorf("Put accepted evil key %q", key) + } + if _, err := b.Get(key); err == nil { + t.Errorf("Get accepted evil key %q", key) + } + if err := b.Delete(key); err == nil { + t.Errorf("Delete accepted evil key %q", key) + } + } +} + +func TestSanitizeFilename(t *testing.T) { + cases := [][2]string{ + {"../../etc/passwd", "passwd"}, + {"C:\\Users\\evil\\file.txt", "file.txt"}, + {"normal.txt", "normal.txt"}, + {"ac", "ac"}, + {"", "file"}, + {"..", "file"}, + {".hidden", ".hidden"}, + {"with\x00null.txt", "withnull.txt"}, + {"new\nline.txt", "newline.txt"}, + } + for _, c := range cases { + got := SanitizeFilename(c[0]) + if got != c[1] { + t.Errorf("SanitizeFilename(%q) = %q want %q", c[0], got, c[1]) + } + } + long := strings.Repeat("x", 300) + if got := SanitizeFilename(long); len(got) != MaxFilenameLen { + t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 8755207..a7c491c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -78,7 +78,8 @@ type CanRow struct { } type Store struct { - db *sql.DB + db *sql.DB + blob BlobStore // #38: attachment byte storage (nil in some unit tests) } func OpenStore(path string) (*Store, error) { @@ -95,6 +96,12 @@ func OpenStore(path string) (*Store, error) { if err := s.migrate(); err != nil { return nil, err } + // #38: attachment blobs live beside the database under .files + blobs, err := NewFsBlobStore(path + ".files") + if err != nil { + return nil, err + } + s.blob = blobs return s, nil } @@ -142,9 +149,26 @@ deletion_token TEXT last_viewed INTEGER NOT NULL, PRIMARY KEY (paste_id, viewer_id) )`) // #49: per-viewer read dedupe window + // #38: file attachments, one per paste in iteration 1. mime is sniffed + // server-side before insert; bytes live in the BlobStore keyed + // /. + s.db.Exec(`CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + paste_id TEXT NOT NULL, + filename TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL + )`) + s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_attachments_paste ON attachments(paste_id)`) return err } +// Blobs returns the attachment blob store (nil when unavailable, e.g. some +// unit-test stores). +func (s *Store) Blobs() BlobStore { return s.blob } + // SlugAlphabet is the paste-id charset (no ambiguous chars). var SlugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5a9a411..5b18ace 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -219,6 +219,14 @@ body { .iconbtn.dark-toggle .icon-sun { display: inline-block; } .dark .iconbtn.dark-toggle .icon-sun { display: none; } .dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } +/* #127: gear highlights like selected nav tabs; dark toggle button */ +.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; } +.iconbtn.dark-toggle svg { width: 22px; height: 22px; } +.iconbtn.dark-toggle .icon-moon { display: none; } +.iconbtn.dark-toggle .icon-sun { display: inline-block; } +.dark .iconbtn.dark-toggle .icon-sun { display: none; } +.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } .settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); } .settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; } .settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; } @@ -502,6 +510,9 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } .iconbtn.dark-toggle svg { width: 18px; height: 18px; } .iconbtn.dark-toggle span { display: none; } + .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } + .iconbtn.dark-toggle svg { width: 18px; height: 18px; } + .iconbtn.dark-toggle span { display: none; } /* new paste: stack editor above sidebar, natural page height */ .deck { diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index 777ed47..d4df719 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -63,6 +63,15 @@
+
+

Attachment

+
+ Drop a file here, press Ctrl+V, or click to browse +
+ +
+ +

Custom URL

@@ -180,6 +189,8 @@ const ERROR_MESSAGES = { slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.', content_empty: 'Write or paste something first.', content_too_large: 'This paste is too large. The limit is 5 MB.', + file_too_large: 'File is too large. The limit is 25 MB.', + one_file_only: 'Choose either text or a file for now.', expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.', rate_limited: 'Too many tries. Wait a minute and try again.', }; @@ -250,6 +261,8 @@ content.addEventListener('paste', () => setTimeout(guessLang, 0)); async function create() { // #4: can mode — bundle the editor + extra items into a can via multipart if ($('iscan').checked) return createCan(); + // #38: file attached -> file paste (1 file = 1 paste; text is ignored) + if (attachedFile) return createFilePaste(); const body = { content: content.value, @@ -280,6 +293,12 @@ async function create() { toast('Create failed', 'error'); return; } + finishCreate(data); +} + +// shared success handling for both create paths (#38): result box, copy +// button, password auto-unlock, then redirect to the paste. +function finishCreate(data) { const url = location.origin + '/' + (data.custom_slug || data.id); showResult('' + url + ' ', 'ok'); $('result').dataset.token = data.deletion_token || ''; @@ -299,7 +318,8 @@ async function create() { fd.append('password', $('password').value); fd.append('next', dest); try { - await fetch('/' + data.id, {method: 'POST', body: fd}); + fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; }); + return; } catch(e) {} } // show the paste @@ -307,6 +327,115 @@ async function create() { } $('create').addEventListener('click', create); +// #38: file attachment support — 1 file = 1 paste. Three input paths: +// Ctrl+V paste of a file, upload button (file picker), drag-and-drop. +const MAX_FILE_BYTES = 25 * 1024 * 1024; +let attachedFile = null; // the single attached File, or null + +function humanSize(n) { + if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'; + if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'; + return n + ' B'; +} + +function setAttachedFile(file) { + if (!file) return clearAttachedFile(); + if (file.size > MAX_FILE_BYTES) { + toast('File is too large. The limit is 25 MB.', 'error'); + return; + } + attachedFile = file; + renderFileChip(); + $('file-text-note').style.display = 'block'; +} + +function clearAttachedFile() { + attachedFile = null; + $('file-input').value = ''; + renderFileChip(); + $('file-text-note').style.display = 'none'; +} + +function renderFileChip() { + const slot = $('file-chip-slot'); + slot.innerHTML = ''; + if (!attachedFile) return; + const chip = document.createElement('div'); + chip.className = 'file-chip'; + chip.innerHTML = '' + + '' + humanSize(attachedFile.size) + '' + + ''; + chip.querySelector('.file-chip-name').textContent = attachedFile.name; + chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile); + slot.appendChild(chip); +} + +// (1) file picker via the dropzone click +$('dropzone').addEventListener('click', () => $('file-input').click()); +$('file-input').addEventListener('change', () => { + if ($('file-input').files.length) setAttachedFile($('file-input').files[0]); +}); + +// (2) drag-and-drop onto the dropzone (and the page broadly) +const dz = $('dropzone'); +['dragenter', 'dragover'].forEach(ev => { + document.addEventListener(ev, e => { + if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return; + e.preventDefault(); + dz.classList.add('dragover'); + }); +}); +['dragleave', 'drop'].forEach(ev => { + document.addEventListener(ev, e => { + if (ev === 'drop') e.preventDefault(); + if (e.target === dz || ev === 'drop') dz.classList.remove('dragover'); + }); +}); +document.addEventListener('drop', e => { + if (!e.dataTransfer || !e.dataTransfer.files.length) return; + e.preventDefault(); + setAttachedFile(e.dataTransfer.files[0]); +}); + +// (3) Ctrl+V of a file anywhere on the page +document.addEventListener('paste', e => { + const files = e.clipboardData && e.clipboardData.files; + if (!files || !files.length) return; // normal text paste into the editor + e.preventDefault(); + setAttachedFile(files[0]); +}); + +// file create path: POST multipart. 1 file = 1 paste: when a file is +// attached the editor text is ignored (server enforces this too). +async function createFilePaste() { + const fd = new FormData(); + fd.append('file', attachedFile); + if ($('title').value) fd.append('title', $('title').value); + if ($('custom').value) fd.append('custom_slug', $('custom').value); + if ($('haspw').checked) fd.append('password', $('password').value); + if ($('burn').checked) { + fd.append('burn_after_read', 'true'); + fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1)); + } + if ($('unlisted').checked) fd.append('visibility', 'unlisted'); + const exp = document.querySelector('input[name="exp"]:checked').value; + if (exp === 'custom') { + const dur = composeCustomExpiry(); + if (dur === null) { toast('Check the custom expiry', 'error'); return; } + fd.append('expires_in', dur); + } else if (exp) { + fd.append('expires_in', exp); + } + const res = await fetch('/api/pastes', { method: 'POST', body: fd }); + const data = await res.json(); + if (!res.ok) { + showResult(friendlyError(data), 'err'); + toast('Create failed', 'error'); + return; + } + finishCreate(data); +} + // #4: can creation — POST multipart to /api/pastes/can. The main editor is // the first item; each extra can-item row is another text item. async function createCan() { diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index cfca561..c5c660f 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -40,6 +40,19 @@
{{end}} + {{if .Attachment}} +
+
+ + {{.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}}
{{.Gutter}}
{{.ContentHTML}}
From 7cc6a9c706a496cadbe7bcffad40173f6411372d Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:35:49 -0500 Subject: [PATCH 10/73] Add line wrap toggle for editor and paste viewer (#130) Shared client-side preference in localStorage 'palette-wrap', default off. Toggle button on /new editor head and paste title bar, plus a Line wrap checkbox in the settings menu; all read/write the same preference. Active state uses the accent treatment like other toggles. No em dashes in UI text. --- internal/web/static/app.css | 5 +++++ internal/web/templates/layout.html | 31 ++++++++++++++++++++++++++++ internal/web/templates/new.html | 1 + internal/web/templates/paste.html | 1 + internal/web/templates/settings.html | 2 ++ 5 files changed, 40 insertions(+) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5a9a411..d8dff25 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -237,6 +237,11 @@ body { .theme-card .swatch { width: 26px; height: 26px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); } .theme-name { font-weight: 600; } .iconbtn:hover { color: var(--fg); border-color: var(--muted); } +/* #130: line wrap toggle, accent when active like other toggles */ +.iconbtn.wrap-toggle[aria-pressed="true"], +.btn-icon.wrap-toggle[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); } +html[data-wrap] .codebody { white-space: pre-wrap; } +html[data-wrap] .editor { white-space: pre-wrap; } .iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; } .code-head { display: flex; align-items: center; gap: 10px; padding: 8px 16px; diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 46337d6..ae1162b 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -46,6 +46,37 @@ // do not persist anything here: URL theme is a one-off override document.documentElement.dataset.preset = dark ? PAIRS[t].dark : PAIRS[t].light; })(); +// #130: shared line wrap preference (localStorage 'palette-wrap', default off) +(function () { + function wrapOn() { + try { return localStorage.getItem('palette-wrap') === '1'; } catch (e) { return false; } + } + function wrapApply(on) { + if (on) document.documentElement.setAttribute('data-wrap', '1'); + else document.documentElement.removeAttribute('data-wrap'); + document.querySelectorAll('.wrap-toggle').forEach(function (b) { + b.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + var s = document.getElementById('wrap-setting'); + if (s) s.checked = on; + } + window.paletteWrapToggle = function () { + var on = !wrapOn(); + try { localStorage.setItem('palette-wrap', on ? '1' : '0'); } catch (e) {} + wrapApply(on); + return on; + }; + document.addEventListener('DOMContentLoaded', function () { + wrapApply(wrapOn()); + document.querySelectorAll('.wrap-toggle').forEach(function (b) { + b.addEventListener('click', function () { window.paletteWrapToggle(); }); + }); + var s = document.getElementById('wrap-setting'); + if (s) s.addEventListener('change', function () { + if (s.checked !== wrapOn()) window.paletteWrapToggle(); + }); + }); +})(); {{end}} diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index 7e334be..bd299a4 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -16,6 +16,7 @@ +
diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index cfca561..1858aaa 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -6,6 +6,7 @@

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

{{if .CustomSlug}}/{{.CustomSlug}}{{end}}
+ raw copy {{if .DeletionToken}}delete{{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index f3c195d..fdb481d 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -13,6 +13,8 @@ Dark mode +

Editor

+
From 9f4ed91b8f4adb1daa8452263e7b2267f5a9d56f Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:43:30 -0500 Subject: [PATCH 11/73] Fix result box wrap: word boundaries instead of break-all (#135) --- internal/web/templates/new.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index bd299a4..1189ba9 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -70,7 +70,7 @@ From fa2e9b1bb56ea3319e554c5d19221aa6876bc098 Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:48:25 -0500 Subject: [PATCH 12/73] History: keep pagination over filtered/sorted set, pin pager to page bottom (#133) --- internal/web/static/app.css | 3 ++- internal/web/static/table.js | 30 ++++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 67d0cf8..b856888 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -202,7 +202,8 @@ body { .footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 20.7px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; } /* history */ -.page { max-width: 1200px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; } +.page { max-width: 1200px; margin: 0 auto; padding: 20px 20px 28px; display: flex; flex-direction: column; gap: 16px; min-height: calc(100vh - 76px); } +.pager { position: sticky; bottom: 0; margin-top: auto; z-index: 5; } .head-row { display: flex; align-items: baseline; gap: 14px; } .head-row h1 { font-size: 34.5px; font-weight: 600; } .search { diff --git a/internal/web/static/table.js b/internal/web/static/table.js index 7824080..2eb77ba 100644 --- a/internal/web/static/table.js +++ b/internal/web/static/table.js @@ -90,10 +90,31 @@ const PaletteTable = (() => { if (opts.hasPager && pager && showing) { const pages = Math.max(1, Math.ceil(state.total / opts.perPage)); if (filtered || state.sortKey) { - showing.textContent = state.sortKey - ? 'Sorted by ' + state.sortKey + ' (' + (state.sortDir === 1 ? 'ascending' : 'descending') + ') · ' + items.length.toLocaleString() + ' of ' + state.total.toLocaleString() - : 'Showing ' + items.length.toLocaleString() + ' matches for "' + state.filter + '"'; - pager.innerHTML = ''; + // Pagination operates over the FILTERED set (#133): never hide the bar. + const filtPages = Math.max(1, Math.ceil(items.length / opts.perPage)); + if (state.page > filtPages) state.page = filtPages; + const fOff = (state.page - 1) * opts.perPage; + const view = items.slice(fOff, fOff + opts.perPage); + rows.innerHTML = view.length ? view.map(opts.rowHtml).join('') : ''; + if (!view.length) { + empty.style.display = 'block'; + empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll; + } + showing.textContent = `Showing ${view.length === 0 ? 0 : fOff+1}–${fOff+view.length} of ${items.length.toLocaleString()} ${filtered ? 'matches' : 'sorted'} · page ${state.page} of ${filtPages}`; + const btns = []; + const add = (label, target, o={}) => btns.push(``); + add('‹', state.page-1, {dis: state.page===1}); + const win = new Set([1, 2, state.page-1, state.page, state.page+1, filtPages]); + let last = 0; + for (let i = 1; i <= filtPages; i++) { + if (win.has(i)) { + if (last && i - last > 1) btns.push(''); + add(String(i), i, {on: i===state.page}); + last = i; + } + } + add('›', state.page+1, {dis: state.page===filtPages}); + pager.innerHTML = btns.join(''); } else { showing.textContent = state.total === 0 ? 'Nothing here yet' : `Showing ${off+1}–${Math.min(off+opts.perPage, state.total)} of ${state.total.toLocaleString()} · page ${state.page} of ${pages}`; @@ -126,6 +147,7 @@ const PaletteTable = (() => { if (!th) return; const k = th.dataset.sort; if (state.sortKey === k) { state.sortDir = -state.sortDir; } else { state.sortKey = k; state.sortDir = 1; } + state.page = 1; renderSortIndicators(); load(); }); From 00733358756f2cb1738bd9ea5df3ac97f651216e Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:53:39 -0500 Subject: [PATCH 13/73] #38 QA fix: render GetAttachmentForPaste in paste view; dedupe dark-toggle CSS - renderPaste now looks up the paste's attachment and passes it as 'Attachment' so the paste.html attachment chip/image preview renders - remove duplicated .iconbtn.dark-toggle blocks (3x from stacked branches #127/#128) and duplicate mobile-media variants Verified: go vet + go test pass; live render on :18098 shows attachment-chip + img src=/f/id/name for a png upload (served 200 image/png) and chip-with-filename, no preview, for an html upload. --- internal/web/static/app.css | 22 ---------------------- internal/web/web.go | 7 +++++++ 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5b18ace..39b9357 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -211,22 +211,6 @@ body { .iconbtn.dark-toggle .icon-sun { display: inline-block; } .dark .iconbtn.dark-toggle .icon-sun { display: none; } .dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } -/* #127: gear highlights like selected nav tabs; dark toggle button */ -.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } -.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; } -.iconbtn.dark-toggle svg { width: 22px; height: 22px; } -.iconbtn.dark-toggle .icon-moon { display: none; } -.iconbtn.dark-toggle .icon-sun { display: inline-block; } -.dark .iconbtn.dark-toggle .icon-sun { display: none; } -.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } -/* #127: gear highlights like selected nav tabs; dark toggle button */ -.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } -.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; } -.iconbtn.dark-toggle svg { width: 22px; height: 22px; } -.iconbtn.dark-toggle .icon-moon { display: none; } -.iconbtn.dark-toggle .icon-sun { display: inline-block; } -.dark .iconbtn.dark-toggle .icon-sun { display: none; } -.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } .settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); } .settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; } .settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; } @@ -507,12 +491,6 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } .iconbtn.dark-toggle svg { width: 18px; height: 18px; } .iconbtn.dark-toggle span { display: none; } - .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } - .iconbtn.dark-toggle svg { width: 18px; height: 18px; } - .iconbtn.dark-toggle span { display: none; } - .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } - .iconbtn.dark-toggle svg { width: 18px; height: 18px; } - .iconbtn.dark-toggle span { display: none; } /* new paste: stack editor above sidebar, natural page height */ .deck { diff --git a/internal/web/web.go b/internal/web/web.go index e7ba16f..40a6fc2 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -215,6 +215,12 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC 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 + } data := map[string]any{ "Page": "paste", "ID": row.ID, @@ -241,6 +247,7 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC "ReadsLeftN": readsRemaining, // *int: reads remaining after this view "ReadsTotal": int(row.ReadsLimit.Int64), "JustCreated": justCreated, + "Attachment": attachment, "Host": "this host", } h.renderPage(w, "paste.html", data) From 7dc70d62f6ac2bde8c70bcceda4ce72fbfb1195a Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:56:53 -0500 Subject: [PATCH 14/73] Fix #146: render untitled paste ID fallback as plain text like titled pastes The PASTE column fallback for untitled pastes used the .slug class, which renders as a monospace chip with a background pill. Give it a dedicated paste-name modifier that strips the chip styling (background, padding, radius, mono font) so the fallback looks identical to a titled paste (var(--fg) plain text). The dedicated ID column and URL column chips keep their existing styling. Applied to both /history and /mine tables. --- internal/web/static/app.css | 2 ++ internal/web/templates/history.html | 2 +- internal/web/templates/mine.html | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d8dff25..26449c4 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -285,6 +285,8 @@ tr.row:hover td { background: var(--surface-2); } tr.row:hover td a.slug { color: var(--accent); } td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; } td a.slug:hover { color: var(--accent); } +/* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */ +td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); } .badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; } .badge.lock { color: var(--accent); border-color: var(--accent); } .dim { color: var(--muted-fg); white-space: nowrap; } diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index b6018d0..fcb667f 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -37,7 +37,7 @@ const t = PaletteTable.init({ `` + (it.title ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` - : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + + : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + `${t.esc(it.language || 'text')}` + `${t.fmtSize(it.size)}${it.view_count}${t.ago(it.created_at)}` + diff --git a/internal/web/templates/mine.html b/internal/web/templates/mine.html index f5def5b..2d7ec02 100644 --- a/internal/web/templates/mine.html +++ b/internal/web/templates/mine.html @@ -48,7 +48,7 @@ const t = PaletteTable.init({ `` + (it.title ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` - : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + + : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + `${t.esc(it.language || 'text')}` + `${t.fmtSize(it.size)}${t.ago(it.created_at)}` + From da9039b6060ca63a09086e6048d84d48635e734c Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:58:41 -0500 Subject: [PATCH 15/73] Split new-paste editor header into title pill and settings pill (#147) --- internal/web/static/app.css | 16 +++++++++++++++- internal/web/templates/new.html | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d8dff25..4f04275 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -127,8 +127,19 @@ body { .pane-l-col { display: flex; flex-direction: column; gap: 14px; min-height: 0; } .pane-l-head { flex-shrink: 0; } .editor-head { - display: flex; align-items: center; gap: 12px; padding: 12px 16px; + display: flex; align-items: stretch; gap: 12px; padding: 12px 16px; } +/* #147: title lives in its own pill; select+refresh stay paired in a second pill */ +.editor-head-title, +.editor-head-settings { + display: flex; align-items: center; gap: 8px; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 6px 10px; +} +.editor-head-title { flex: 1; min-width: 0; } +.editor-head-settings { flex: 0 0 fit-content; } +.editor-head-title:focus-within, +.editor-head-settings:focus-within { border-color: var(--accent); } .editor-head input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 23.2px; flex: 1; } @@ -519,8 +530,11 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } .editor-wrap { min-height: 45vh; } .editor { font-size: 15px; } .gutter { font-size: 15px; } + .editor-head { flex-wrap: wrap; } + .editor-head-title { flex: 1 1 100%; } .editor-head input { min-width: 0; font-size: 16px; } .editor-head select { max-width: 120px; font-size: 14px; } + .editor-head-title, .editor-head-settings { padding: 4px 8px; } .actionbar { flex-wrap: wrap; } .actionbar .btn { padding: 12px 22px; } .hint { font-size: 13px; } diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index bd299a4..4099cd0 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -4,7 +4,10 @@
- +
+ +
+
+
From d013f3965fd86e4965c61be6ae7f21538369439b Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 23:23:10 -0500 Subject: [PATCH 16/73] Require secrets via headers only: drop ?key= and ?password= query fallbacks (#137, #141) Query strings leak into Traefik access logs, browser history, and Referer headers. Admin key is now accepted only via X-Admin-Key; paste and can passwords only via X-Paste-Password (or the POST unlock form). Tests updated; new negative cases assert 401 for the query paths. --- internal/api/admin.go | 9 ++++----- internal/api/admin_test.go | 5 +++-- internal/api/cans.go | 6 ------ internal/api/cans_flow_test.go | 3 ++- internal/api/cans_test.go | 6 ++++-- internal/api/issue81_password_ratelimit_test.go | 12 +++++++----- internal/api/main_test.go | 6 ++++-- internal/api/server.go | 11 +++++------ 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index b88d9dc..01c3246 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -160,13 +160,12 @@ func HandleResetAdminKey(dbPath string) { } // adminKeyOK reports whether the request carries the correct admin key via -// X-Admin-Key header or ?key=. Constant-time compare; failures and successes -// are both logged (#40). +// the X-Admin-Key header only. The ?key= query fallback was removed (#137): +// query strings land in access logs, browser history, and Referer headers, +// so accepting the key there leaked the admin secret. Constant-time compare; +// failures and successes are both logged (#40). func (a *apiServer) adminKeyOK(r *http.Request, key string) bool { given := r.Header.Get("X-Admin-Key") - if given == "" { - given = r.URL.Query().Get("key") - } return subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 } diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index 8dc2dbc..67900d0 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -37,11 +37,12 @@ func TestAdminAuth(t *testing.T) { t.Fatalf("wrong key: expected 401, got %d", rec.Code) } + // #137: the ?key= query fallback was removed; keys must go via header. req = httptest.NewRequest("GET", "/admin/api/settings?key=test-admin-key", nil) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) - if rec.Code != 200 { - t.Fatalf("query key: expected 200, got %d", rec.Code) + if rec.Code != 401 { + t.Fatalf("query key: expected 401 after #137 removal, got %d", rec.Code) } req = httptest.NewRequest("GET", "/admin/api/settings", nil) diff --git a/internal/api/cans.go b/internal/api/cans.go index 9107bc3..0583dfd 100644 --- a/internal/api/cans.go +++ b/internal/api/cans.go @@ -206,9 +206,6 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) { } if can.PasswordHash.Valid { pw := r.Header.Get("X-Paste-Password") - if pw == "" { - pw = r.URL.Query().Get("password") - } if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) { writeErr(w, 401, "password required") return @@ -256,9 +253,6 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) { can, _ := a.store.GetCan(row.CanID.String) if can != nil && can.PasswordHash.Valid { pw := r.Header.Get("X-Paste-Password") - if pw == "" { - pw = r.URL.Query().Get("password") - } if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) { // fall back to the browser's unlock cookie for this can c, cerr := r.Cookie("pw_" + can.ID) diff --git a/internal/api/cans_flow_test.go b/internal/api/cans_flow_test.go index 308587d..44c3c8f 100644 --- a/internal/api/cans_flow_test.go +++ b/internal/api/cans_flow_test.go @@ -294,7 +294,8 @@ func TestCanItemCookieParity(t *testing.T) { } // item id from API (with password query) - req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"?password=pw123", nil) + req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil) + req.Header.Set("X-Paste-Password", "pw123") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var can struct { diff --git a/internal/api/cans_test.go b/internal/api/cans_test.go index e6ac0ec..4723f4e 100644 --- a/internal/api/cans_test.go +++ b/internal/api/cans_test.go @@ -112,7 +112,8 @@ func TestCanPasswordInheritedByItems(t *testing.T) { } // get item id with pw - req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"?password=pw123", nil) + req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil) + req.Header.Set("X-Paste-Password", "pw123") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var can struct { @@ -130,7 +131,8 @@ func TestCanPasswordInheritedByItems(t *testing.T) { } // item with pw -> 200 - req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID+"?password=pw123", nil) + req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil) + req.Header.Set("X-Paste-Password", "pw123") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { diff --git a/internal/api/issue81_password_ratelimit_test.go b/internal/api/issue81_password_ratelimit_test.go index f33d91f..8ec49bf 100644 --- a/internal/api/issue81_password_ratelimit_test.go +++ b/internal/api/issue81_password_ratelimit_test.go @@ -2,7 +2,7 @@ package api // #81: ALL password verification attempts (GET query param, header, POST // form) must go through the per-IP unlock limiter. Regression: N wrong -// passwords via GET ?password= must eventually yield 429. +// passwords via X-Paste-Password must eventually yield 429. import ( "encoding/json" @@ -26,7 +26,7 @@ func createPasswordPaste(t *testing.T, s *apiServer, pw string) string { } // TestRateLimitGetPasswordQuery: repeated wrong passwords via GET -// ?password= must eventually return 429 (unlock limiter: burst 5). +// X-Paste-Password wrong attempts must eventually return 429 (unlock limiter: burst 5). func TestRateLimitGetPasswordQuery(t *testing.T) { s := testServer(t) h := s.routes() @@ -35,7 +35,8 @@ func TestRateLimitGetPasswordQuery(t *testing.T) { var saw429 bool // more attempts than the unlock burst (5) for i := 0; i < 10; i++ { - req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=wrong"+string(rune('a'+i)), nil) + req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) + req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i))) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == 429 { @@ -47,7 +48,7 @@ func TestRateLimitGetPasswordQuery(t *testing.T) { } } if !saw429 { - t.Fatal("expected 429 after repeated wrong ?password= attempts, never got one") + t.Fatal("expected 429 after repeated wrong password attempts, never got one") } } @@ -83,7 +84,8 @@ func TestRateLimitGetPasswordCorrectStillAllowed(t *testing.T) { h := s.routes() id := createPasswordPaste(t, s, "hunter2") - req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=hunter2", nil) + req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) + req.Header.Set("X-Paste-Password", "hunter2") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { diff --git a/internal/api/main_test.go b/internal/api/main_test.go index 55011d2..e3548ce 100644 --- a/internal/api/main_test.go +++ b/internal/api/main_test.go @@ -89,7 +89,8 @@ func TestPasswordProtection(t *testing.T) { } // with password -> 200 - req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=hunter2", nil) + req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) + req.Header.Set("X-Paste-Password", "hunter2") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { @@ -97,7 +98,8 @@ func TestPasswordProtection(t *testing.T) { } // wrong password -> 401 - req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=nope", nil) + req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) + req.Header.Set("X-Paste-Password", "nope") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 401 { diff --git a/internal/api/server.go b/internal/api/server.go index a1a2ae4..ba67bd4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -267,18 +267,17 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) { return } if row.PasswordHash.Valid { - // #81: every password verification (header, query param, or empty) - // goes through the same per-IP+paste unlock limiter as the POST form - // path, so brute-force via GET ?password= or X-Paste-Password gets 429. + // #81/#141: every password verification (header or empty) goes + // through the same per-IP+paste unlock limiter as the POST form + // path, so brute-force via X-Paste-Password gets 429. The + // ?password= query fallback was removed (#141): query strings + // leak into access logs, browser history, and Referer headers. if !rateLimitUnlock(row.ID, r) { writeRateLimited(w, 60) return } // require password via header or query pw := r.Header.Get("X-Paste-Password") - if pw == "" { - pw = r.URL.Query().Get("password") - } if pw == "" || !store.CheckPassword(row.PasswordHash.String, pw) { writeErr(w, 401, "password required") return From 72dbfb1b3ceca3d52c455075525efed99bb25b93 Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 23:25:15 -0500 Subject: [PATCH 17/73] Fix invisible select chevron in all presets (#136) Two compounding causes: - CSP img-src fell back to default-src (self), blocking the data: URI chevron image on all served pages, so no select ever painted it. - .editor-head select used the background shorthand, wiping the background-image set by the later select rule. Chevron stroke is now a per-preset --chevron token (light stroke for dark presets, dark for light presets) instead of hardcoded #888. CSP gains img-src 'self' data: so the data-URI icons can load. --- internal/web/securityheaders_test.go | 2 +- internal/web/static/app.css | 16 ++++++++++++++-- internal/web/web.go | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/web/securityheaders_test.go b/internal/web/securityheaders_test.go index 61d0b56..c8b5e17 100644 --- a/internal/web/securityheaders_test.go +++ b/internal/web/securityheaders_test.go @@ -16,7 +16,7 @@ func TestSecurityHeaders(t *testing.T) { h := SecurityHeaders(pages) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) - wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'" + wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'" if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP { t.Errorf("CSP = %q, want %q", got, wantCSP) } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 26449c4..d1e7147 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -4,6 +4,8 @@ --muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8; --accent: #C4A8F0; --border: #42355C; --radius-lg: 20px; --radius: 10px; --radius-sm: 999px; + --chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23C8C8D4' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: select chevron stroke (dark presets) */ + --chevron-fg: #C8C8D4; --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace; } @@ -18,6 +20,8 @@ --accent: #7A7796; --border: #DAD7E6; --ok: #456F45; --warn: #7A5E1B; --err: #9E4054; --on-accent: #F6F5FA; + --chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */ + --chevron-fg: #555560; } [data-preset="pastel-lavender"] { --bg: #e6e0f5; --surface: #f1edfa; --surface-2: #cbb8e7; @@ -25,6 +29,8 @@ --accent: #806bb8; --border: #c4b6e0; --ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054; --on-accent: #f1edfa; + --chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */ + --chevron-fg: #555560; } [data-preset="pastel-peach"] { --bg: #ffe0d6; --surface: #fff0ea; --surface-2: #ffc4a8; @@ -32,6 +38,8 @@ --accent: #f9826c; --border: #ffc9b5; --ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054; --on-accent: #4F2318; + --chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */ + --chevron-fg: #555560; } [data-preset="pastel-cloud"] { --bg: #fff0f6; --surface: #fff7fb; --surface-2: #ffc8dd; @@ -39,6 +47,8 @@ --accent: #a2d2ff; --border: #ffccd9; --ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054; --on-accent: #274a6b; + --chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */ + --chevron-fg: #555560; } /* matching light/dark counterparts: same hue family, flipped lightness roles */ /* Midnight Light: light counterpart of midnight (violet family) */ @@ -48,6 +58,8 @@ --accent: #7B5FC0; --border: #D8CEEC; --ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054; --on-accent: #FAF8FD; + --chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */ + --chevron-fg: #555560; } /* Smooth Dark: dark counterpart of smooth (muted slate-violet family) */ [data-preset="smooth-dark"] { @@ -133,7 +145,7 @@ body { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 23.2px; flex: 1; } .editor-head select { - border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); + border: 1px solid var(--border); background-color: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 4px 10px; font: inherit; font-size: 21.6px; cursor: pointer; } /* shared code line metrics (#50): gutter + code must share one line box */ @@ -587,7 +599,7 @@ select { -webkit-appearance: none; border: 1px solid var(--border); border-radius: var(--radius); - background: var(--bg) url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center; + background: var(--bg) var(--chevron) no-repeat right 10px center; /* #136: themed chevron */ color: var(--fg); font: inherit; padding: 4px 30px 4px 12px; diff --git a/internal/web/web.go b/internal/web/web.go index e7ba16f..8bc7301 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -383,7 +383,7 @@ func SecurityHeaders(next http.Handler) http.Handler { // is harmless and arguably desirable. h := w.Header() h.Set("Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'") + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Content-Type-Options", "nosniff") next.ServeHTTP(w, r) From 747eba70648d8d9ed43a66bee13f332b9c84fc3a Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:38:57 -0500 Subject: [PATCH 18/73] Settings: dark toggle above theme list; midnight card light row on top (#132) --- internal/web/templates/settings.html | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index fdb481d..1720424 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -6,13 +6,13 @@

Settings

-

Theme

-
+

Theme

+

Editor

@@ -55,9 +55,21 @@ var grid = document.getElementById('theme-grid'); var cards = {}; + // #132: midnight is dark-first (root preset = midnight = dark; its light + // variant is midnight-light), the others are light-first. Resolve the + // LIGHT and DARK preset ids generically so the light swatches always + // render in the top row of every card. + function lightPreset(id) { + if (id === 'midnight') return 'midnight-light'; + return id; // light-first bases use themselves as the light variant + } + function darkPreset(id) { + if (id === 'midnight') return 'midnight'; // root preset is midnight's dark + return id + '-dark'; + } pairs.forEach(function (t) { - var light = presetColors(t.id); - var dark = presetColors(t.id + '-dark'); + var light = presetColors(lightPreset(t.id)); + var dark = presetColors(darkPreset(t.id)); var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'theme-card'; From d7b51f02b62b875ccb5f9f1448cb5a7559c446fb Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 08:24:05 -0500 Subject: [PATCH 19/73] Remove ?token= deletion-token path (#143) The deletion token was accepted via the ?token= query parameter on both DELETE /api/pastes/{id} and /redeem, and round-tripped through the paste URL after creation. URL-carried bearer secrets leak into reverse-proxy access logs and browser history. - API: deletion tokens are now accepted only via the Authorization header (Bearer/Token/bare); query params are ignored on both endpoints - Web create flow: token moves to the browser via a short-lived tok_ HttpOnly cookie instead of the redirect URL; the paste view reads it from the cookie, never from ?token= - Web view: the delete button calls redeem() which takes the token from sessionStorage and sends it as an Authorization header - Tests: correct token in query must be rejected (403/400); header path still deletes/redeems; extraction unit cases updated Fixes #143 --- internal/api/burn.go | 6 ++-- internal/api/burn_test.go | 8 +++-- internal/api/delete_auth_test.go | 58 ++++++++++++++++++++++++------- internal/api/server.go | 17 +++++---- internal/web/templates/new.html | 4 ++- internal/web/templates/paste.html | 9 +++-- internal/web/web.go | 8 ++++- 7 files changed, 81 insertions(+), 29 deletions(-) diff --git a/internal/api/burn.go b/internal/api/burn.go index a425ac2..2e108a6 100644 --- a/internal/api/burn.go +++ b/internal/api/burn.go @@ -20,10 +20,12 @@ func (a *apiServer) burnViewerWindow() int { } // handleRedeemDeletion lets a holder of the deletion token hard-delete immediately. -// DELETE /api/pastes/{id}/redeem?token=... +// DELETE /api/pastes/{id}/redeem with the token in the Authorization header +// (#143: the ?token= query path was removed so the secret stays out of +// access logs and browser history). func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - token := r.URL.Query().Get("token") + token := deletionAuthorization(r) if token == "" { writeErr(w, 400, "token required") return diff --git a/internal/api/burn_test.go b/internal/api/burn_test.go index cdaa2ef..e67c309 100644 --- a/internal/api/burn_test.go +++ b/internal/api/burn_test.go @@ -53,8 +53,9 @@ func TestDeletionTokenRedeem(t *testing.T) { t.Fatal("no deletion token in create response") } - // wrong token - req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token=wrong", nil) + // wrong token (#143: token goes in the Authorization header, not the URL) + req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem", nil) + req.Header.Set("Authorization", "Bearer wrong") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 403 { @@ -62,7 +63,8 @@ func TestDeletionTokenRedeem(t *testing.T) { } // right token: hard delete - req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token="+created.DeletionToken, nil) + req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem", nil) + req.Header.Set("Authorization", "Bearer "+created.DeletionToken) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { diff --git a/internal/api/delete_auth_test.go b/internal/api/delete_auth_test.go index 9581b1d..a2cddde 100644 --- a/internal/api/delete_auth_test.go +++ b/internal/api/delete_auth_test.go @@ -1,9 +1,10 @@ package api -// Regression tests for #63: DELETE /api/pastes/{id} must require the -// deletion token (Authorization header or ?token= query param, constant-time -// compare). Without a token, or with a wrong token, the paste must survive -// and the response must be 403. +// Regression tests for #63 and #143: DELETE /api/pastes/{id} must require the +// deletion token in the Authorization header (constant-time compare). The +// ?token= query parameter is NOT accepted (#143): URL-carried tokens leak +// into access logs and browser history. Without a token, or with a wrong +// token, the paste must survive and the response must be 403. import ( "encoding/json" @@ -68,10 +69,14 @@ func TestDeleteWithWrongTokenForbidden(t *testing.T) { h := s.routes() id, _ := createTestPaste(t, h) - // query param - rec := doReq(t, h, "DELETE", "/api/pastes/"+id+"?token=wrong-token", "", "") + // query param: even the CORRECT token must be rejected now (#143) + id2, tok2 := createTestPaste(t, h) + rec := doReq(t, h, "DELETE", "/api/pastes/"+id2+"?token="+tok2, "", "") if rec.Code != http.StatusForbidden { - t.Fatalf("delete with wrong token (query): got %d want 403", rec.Code) + t.Fatalf("delete with correct token in query: got %d want 403 (#143)", rec.Code) + } + if !pasteExists(t, h, id2) { + t.Fatal("paste was deleted via ?token= query param (#143 regression)") } // header req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil) @@ -103,14 +108,43 @@ func TestDeleteWithCorrectToken(t *testing.T) { t.Fatal("paste still exists after authorized delete") } - // via query param + // query param: even with the correct token the delete must fail (#143) id, tok = createTestPaste(t, h) rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"?token="+tok, "", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("delete with correct token (query): got %d want 403 (#143)", rec.Code) + } + if !pasteExists(t, h, id) { + t.Fatal("paste was deleted via ?token= query param (#143 regression)") + } +} + +// #143: the deletion token must be accepted via the Authorization header on +// the redeem (hard delete) endpoint too. +func TestRedeemWithCorrectTokenHeader(t *testing.T) { + s := testServer(t) + h := s.routes() + id, tok := createTestPaste(t, h) + + req := httptest.NewRequest("DELETE", "/api/pastes/"+id+"/redeem", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) if rec.Code != 200 { - t.Fatalf("delete with correct token (query): got %d want 200", rec.Code) + t.Fatalf("redeem with correct token (header): got %d want 200: %s", rec.Code, rec.Body.String()) } if pasteExists(t, h, id) { - t.Fatal("paste still exists after authorized delete (query)") + t.Fatal("paste still exists after authorized redeem") + } + + // query param must NOT work on redeem either + id, tok = createTestPaste(t, h) + rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"/redeem?token="+tok, "", "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("redeem via ?token= query: got %d want 400 (#143)", rec.Code) + } + if !pasteExists(t, h, id) { + t.Fatal("paste was hard-deleted via ?token= query param (#143 regression)") } } @@ -161,8 +195,8 @@ func TestDeletionAuthorizationExtract(t *testing.T) { {"bearer tok", "", "tok"}, {"Token tok", "", "tok"}, {"tok", "", "tok"}, - {"", "?token=q", "q"}, - {"Bearer hdr", "?token=q", "hdr"}, // header wins + {"", "?token=q", ""}, // #143: query tokens are never accepted + {"Bearer hdr", "?token=q", "hdr"}, // header only } for _, c := range cases { if got := deletionAuthorization(mk(c.hdr, c.q)); got != c.want { diff --git a/internal/api/server.go b/internal/api/server.go index 24d6924..2913baf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -311,9 +311,10 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) { return } // #63: deletion requires authorization. Either the deletion token issued - // at create time (Authorization header or ?token= query param, matching - // the create response's "deletion_token" field), or the creator browser - // itself (client-sent vwr cookie matching the paste's viewer, #37). + // at create time (Authorization header; #143 removed the ?token= query + // path so the bearer secret never lands in access logs or history), or + // the creator browser itself (client-sent vwr cookie matching the + // paste's viewer, #37). if !a.deletionAuthorized(r, row) { writeErr(w, 403, "deletion token required") return @@ -325,9 +326,11 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"status": "soft-deleted"}) } -// deletionAuthorization extracts the deletion token from the request: the -// Authorization header ("Bearer ", "Token ", or a bare token) or the -// token query parameter. Returns "" when absent. +// deletionAuthorization extracts the deletion token from the request: +// the Authorization header ("Bearer ", "Token ", or a bare token). +// The ?token= query parameter is deliberately NOT accepted (#143): URL +// query strings end up in proxy access logs and browser history. Returns +// "" when absent. func deletionAuthorization(r *http.Request) string { if h := r.Header.Get("Authorization"); h != "" { for _, prefix := range []string{"Bearer ", "Token "} { @@ -337,7 +340,7 @@ func deletionAuthorization(r *http.Request) string { } return strings.TrimSpace(h) } - return r.URL.Query().Get("token") + return "" } // deletionAuthorized reports whether the request may soft-delete the paste: diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index 2edd883..b51e191 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -291,7 +291,9 @@ function finishCreate(data) { setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000); } catch(e) { toast('Copy failed', 'error'); } }); - const dest = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || ''); + // token carried via sessionStorage, never in the URL (#143) + const dest = '/' + data.id + '?created=1'; + try { sessionStorage.setItem('deletion_token_' + data.id, data.deletion_token || ''); } catch(e) {} // password-protected: unlock now with the password we already have (#26) if ($('haspw').checked && data.id) { const fd = new FormData(); diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index 8ee6053..ba9bae7 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -9,7 +9,7 @@ raw copy - {{if .DeletionToken}}delete{{end}} + {{if .DeletionToken}}delete{{end}}
@@ -89,9 +89,12 @@ function copyContent(btn) { toast('Copied', 'success'); } } -function redeem(token) { +function redeem() { if (!confirm('Hard delete this paste immediately?')) return; - fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'}) + let tok = ''; + try { tok = sessionStorage.getItem('deletion_token_{{.ID}}') || ''; } catch(e) {} + if (!tok) { alert('deletion token not available in this browser'); return; } + fetch('/api/pastes/{{.ID}}/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}}) .then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); }); } diff --git a/internal/web/web.go b/internal/web/web.go index 2f9a3b1..b3a8166 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -310,7 +310,13 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { } justCreated := r.URL.Query().Get("created") == "1" - token := r.URL.Query().Get("token") + // #143: the deletion token is no longer round-tripped through the URL + // (?token= leaks into access logs and history). The create flow sets a + // short-lived tok_ cookie; the paste view reads it once from there. + token := "" + if c, err := r.Cookie("tok_" + row.ID); err == nil { + token = c.Value + } if justCreated && token != "" { // one-time display of the deletion token via the created banner http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode}) From 00aaafeb3c84772b2eda065bb08110d26971051b Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 08:36:47 -0500 Subject: [PATCH 20/73] #143: set tok_ cookie in create handlers so the created banner can show the token (QA) --- internal/api/attachments.go | 1 + internal/api/cookie_set_test.go | 97 +++++++++++++++++++++++++++++++++ internal/api/server.go | 35 ++++++++---- 3 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 internal/api/cookie_set_test.go diff --git a/internal/api/attachments.go b/internal/api/attachments.go index 04c4c56..862f544 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -242,6 +242,7 @@ func (a *apiServer) handleCreatePasteMultipart(w http.ResponseWriter, r *http.Re return } + setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143 resp := map[string]any{ "id": created.ID, "deletion_token": created.DeletionToken, diff --git a/internal/api/cookie_set_test.go b/internal/api/cookie_set_test.go new file mode 100644 index 0000000..7311560 --- /dev/null +++ b/internal/api/cookie_set_test.go @@ -0,0 +1,97 @@ +package api + +// #143: create responses must set the short-lived tok_ HttpOnly cookie +// that the paste view reads for the one-time created banner. + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCreateSetsDeletionTokenCookie(t *testing.T) { + s := testServer(t) + h := s.routes() + + // JSON create + body := `{"content":"hello #143 cookie"}` + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("json create: got %d", rec.Code) + } + found := false + for _, c := range rec.Result().Cookies() { + if strings.HasPrefix(c.Name, "tok_") && c.Value != "" { + found = true + if !c.HttpOnly { + t.Error("tok_ cookie not HttpOnly") + } + if c.MaxAge != 60 { + t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge) + } + } + } + if !found { + t.Error("json create did not set tok_ cookie (#143)") + } + + // multipart create + var buf strings.Builder + boundary := "----qa143" + buf.WriteString("--" + boundary + "\r\n") + buf.WriteString("Content-Disposition: form-data; name=\"content\"\r\n\r\n") + buf.WriteString("multipart #143\r\n") + buf.WriteString("--" + boundary + "--\r\n") + req2 := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(buf.String())) + req2.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + if rec2.Code != 201 { + t.Fatalf("multipart create: got %d body=%s", rec2.Code, rec2.Body.String()) + } + found = false + for _, c := range rec2.Result().Cookies() { + if strings.HasPrefix(c.Name, "tok_") && c.Value != "" { + found = true + } + } + if !found { + t.Error("multipart create did not set tok_ cookie (#143)") + } +} + +func TestCreatedBannerViaCookie(t *testing.T) { + s := testServer(t) + h := s.routes() + body := `{"content":"banner flow #143"}` + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("create: got %d", rec.Code) + } + var id, tok string + for _, c := range rec.Result().Cookies() { + if strings.HasPrefix(c.Name, "tok_") { + id = strings.TrimPrefix(c.Name, "tok_") + tok = c.Value + } + } + if id == "" || tok == "" { + t.Fatal("no tok_ cookie from create") + } + // follow the redirect the browser would make: GET /?created=1 with the cookie + req2 := httptest.NewRequest("GET", "/"+id+"?created=1", nil) + req2.AddCookie(&http.Cookie{Name: "tok_" + id, Value: tok}) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + if rec2.Code != 200 { + t.Fatalf("paste view: got %d", rec2.Code) + } + if !strings.Contains(rec2.Body.String(), tok) { + t.Error("created banner does not show the deletion token (#143 cookie flow broken)") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 2913baf..fb2cd7c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -174,6 +174,16 @@ func viewerSentCookie(r *http.Request) bool { return !minted } +// #143: hand the deletion token to the creator's browser via a short-lived +// HttpOnly cookie instead of the URL. The paste view reads it once to show +// the one-time created banner; it expires after 60s. +func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) { + http.SetCookie(w, &http.Cookie{ + Name: "tok_" + pasteID, Value: token, Path: "/", + MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, + }) +} + func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { s := a.settings.get() setRateLimitHeaders(w, 1, 5) @@ -241,6 +251,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { writeErrCode(w, 400, createErrCode(err), err.Error()) return } + setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143 writeJSON(w, 201, map[string]any{ "id": created.ID, "deletion_token": created.DeletionToken, @@ -554,19 +565,19 @@ func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) { cards = append(cards, ci) } h.RenderPage(w, "can.html", map[string]any{ - "Page": "can", - "ID": can.ID, - "Title": nullStrOr(can.Title, "Untitled can"), - "Description": can.Description.String, + "Page": "can", + "ID": can.ID, + "Title": nullStrOr(can.Title, "Untitled can"), + "Description": can.Description.String, "HasDescription": can.Description.Valid && can.Description.String != "", - "HasPassword": can.PasswordHash.Valid, - "Items": cards, - "ItemCount": len(cards), - "SizeHuman": web.HumanSize(totalSize), - "CreatedAgo": web.AgoString(can.CreatedAt), - "CreatedAtUnix": can.CreatedAt, - "ExpiresAt": can.ExpiresAt.Valid, - "ExpiresIn": expiryStringIfValid(can.ExpiresAt), + "HasPassword": can.PasswordHash.Valid, + "Items": cards, + "ItemCount": len(cards), + "SizeHuman": web.HumanSize(totalSize), + "CreatedAgo": web.AgoString(can.CreatedAt), + "CreatedAtUnix": can.CreatedAt, + "ExpiresAt": can.ExpiresAt.Valid, + "ExpiresIn": expiryStringIfValid(can.ExpiresAt), }) } From 521b6f8011dfd9c4dcfc5bc7f86da1696e9455e5 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 08:32:09 -0500 Subject: [PATCH 21/73] #138: add Secure attribute to viewer cookie (vwr) --- internal/api/issue138_cookie_test.go | 67 ++++++++++++++++++++++++++++ internal/api/server.go | 5 ++- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 internal/api/issue138_cookie_test.go diff --git a/internal/api/issue138_cookie_test.go b/internal/api/issue138_cookie_test.go new file mode 100644 index 0000000..d5e8ca2 --- /dev/null +++ b/internal/api/issue138_cookie_test.go @@ -0,0 +1,67 @@ +package api + +// #138: the vwr viewer cookie must carry the Secure attribute. Tests inspect +// the Set-Cookie header directly rather than relying on cookie round-tripping, +// because Go's HTTP client (and browsers) drop Secure cookies over plain HTTP, +// which is how tests and local dev run. + +import ( + "net/http/httptest" + "strings" + "testing" + + "palette/internal/store" + "palette/internal/web" +) + +func newTestServer138(t *testing.T) *httptest.ResponseRecorder { + t.Helper() + 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"} + req := httptest.NewRequest("GET", "/history", nil) + rec := httptest.NewRecorder() + a.routes().ServeHTTP(rec, req) + return rec +} + +func TestViewerCookieAttributes(t *testing.T) { + rec := newTestServer138(t) + var vwr *string + for _, c := range rec.Result().Cookies() { + if c.Name == "vwr" { + cc := c + vwr = &cc.Value + if !c.Secure { + t.Error("vwr cookie missing Secure attribute (#138)") + } + if !c.HttpOnly { + t.Error("vwr cookie missing HttpOnly attribute") + } + if c.Path != "/" { + t.Errorf("vwr cookie Path = %q, want /", c.Path) + } + if c.SameSite != 2 { // http.SameSiteLaxMode + t.Errorf("vwr cookie SameSite = %v, want Lax", c.SameSite) + } + } + } + if vwr == nil { + t.Fatal("no vwr cookie set") + } + // also confirm the raw header form spells out Secure + sc := rec.Header().Get("Set-Cookie") + if !strings.Contains(sc, "Secure") { + t.Errorf("Set-Cookie header %q lacks Secure", sc) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index fb2cd7c..cf2aaf7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -141,9 +141,12 @@ func viewerCookieMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("vwr"); err != nil || c.Value == "" { id := store.GenSlug(16) + // #138: Secure keeps the viewer id off plain-HTTP requests + // (all deployments are HTTPS-only behind traefik). http.SetCookie(w, &http.Cookie{ Name: "vwr", Value: id, Path: "/", - MaxAge: 31536000, HttpOnly: true, SameSite: http.SameSiteLaxMode, + MaxAge: 31536000, HttpOnly: true, Secure: true, + SameSite: http.SameSiteLaxMode, }) r.AddCookie(&http.Cookie{Name: "vwr", Value: id}) // remember that this cookie was minted here, not sent by the client From b30d41b87da607c335e19e21521138ba3db84791 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 08:43:31 -0500 Subject: [PATCH 22/73] Wrap ON breaks long tokens mid-word; no horizontal scroll while wrap enabled (#152) - codebody/editor: overflow-wrap anywhere + word-break break-all + overflow-x hidden under html[data-wrap] - code container and mobile .float also hidden overflow with wrap on - topbar mobile: tighten gap/padding/nav link padding so nothing overflows at 375px Fixes #152 --- internal/web/static/app.css | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index bdae956..88e9b0a 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -263,8 +263,12 @@ body { /* #130: line wrap toggle, accent when active like other toggles */ .iconbtn.wrap-toggle[aria-pressed="true"], .btn-icon.wrap-toggle[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); } -html[data-wrap] .codebody { white-space: pre-wrap; } -html[data-wrap] .editor { white-space: pre-wrap; } +/* #152: wrap ON must break long unbroken tokens mid-word and allow no horizontal scrolling */ +html[data-wrap] .codebody { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; overflow-x: hidden; } +html[data-wrap] .editor { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; overflow-x: hidden; } +/* #152: with wrap on nothing may scroll horizontally, including the code container and mobile floats */ +html[data-wrap] .code { overflow-x: hidden; } +html[data-wrap] .float { overflow-x: hidden; } .iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; } .code-head { display: flex; align-items: center; gap: 10px; padding: 8px 16px; @@ -520,11 +524,11 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } body { font-size: 16px; } /* topbar: tighten so logo + nav + gear fit */ - .topbar { gap: 10px; padding: 0 12px; height: 56px; } + .topbar { gap: 6px; padding: 0 10px; height: 56px; } .logo { font-size: 17px; white-space: nowrap; } .logo em { display: none; } .topbar nav { gap: 2px; flex-shrink: 0; } - .topbar nav a { padding: 5px 8px; font-size: 15px; } + .topbar nav a { padding: 4px 6px; font-size: 15px; } .iconbtn.gear { padding: 4px 7px; flex-shrink: 0; } .iconbtn.gear svg { width: 18px; height: 18px; } .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } From 7a74ff3e746ec531316d8b65c4f5e348800055ba Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 09:08:17 -0500 Subject: [PATCH 23/73] Fix #139: drop unsafe-inline from script-src and style-src (#139) - Move all inline `) + srcOnly := regexp.MustCompile(`]+src=`) + for _, e := range entries { + b, err := fs.ReadFile(tmplFS, "templates/"+e.Name()) + if err != nil { + t.Fatal(err) + } + for _, m := range re.FindAll(b, -1) { + if srcOnly.Match(m) { + continue // external script tag with src: fine + } + t.Errorf("%s: inline + {{template "foot" .}} diff --git a/internal/web/templates/can.html b/internal/web/templates/can.html index 2c21ae4..99ec9f3 100644 --- a/internal/web/templates/can.html +++ b/internal/web/templates/can.html @@ -1,7 +1,7 @@ {{template "head" .}} {{template "topbar" .}}
-
+

{{.Title}} can

{{if .HasDescription}}

{{.Description}}

{{end}} diff --git a/internal/web/templates/foot.html b/internal/web/templates/foot.html index 850e1fc..522a337 100644 --- a/internal/web/templates/foot.html +++ b/internal/web/templates/foot.html @@ -1,21 +1 @@ -{{define "foot"}}{{end}} \ No newline at end of file +{{define "foot"}}{{end}} \ No newline at end of file diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index fcb667f..cb6da95 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -8,7 +8,7 @@
- + @@ -20,7 +20,7 @@
Paste Language
- +
@@ -28,25 +28,5 @@
- + {{template "foot" .}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index ae1162b..ca8cbfc 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -2,82 +2,7 @@ - + {{end}} {{define "topbar"}} @@ -98,49 +23,5 @@
- + {{end}} diff --git a/internal/web/templates/mine.html b/internal/web/templates/mine.html index 2d7ec02..31bfdc4 100644 --- a/internal/web/templates/mine.html +++ b/internal/web/templates/mine.html @@ -8,7 +8,7 @@
- + @@ -19,7 +19,7 @@
Paste Language
- +
@@ -27,51 +27,5 @@
- + {{template "foot" .}} diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index b51e191..c2a7b4c 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -30,7 +30,7 @@
Ctrl+Enter to create -
+
@@ -46,8 +46,8 @@
-

Protection

- + - +
@@ -75,414 +75,17 @@
- +

Custom URL

- - + {{template "foot" .}} diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index ba9bae7..6040efb 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -8,13 +8,13 @@
raw - copy - {{if .DeletionToken}}delete{{end}} + copy + {{if .DeletionToken}}delete{{end}}
- @@ -35,7 +35,7 @@
{{if .JustCreated}}
-
+
Paste created. Link copied to clipboard: {{.Host}}/{{.ID}} {{if .DeletionToken}} · deletion token: {{.DeletionToken}}{{end}}
@@ -59,43 +59,5 @@
- + {{template "foot" .}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index 1720424..dff3571 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -13,102 +13,11 @@

Theme

-

Editor

- +

Editor

+
- + {{template "foot" .}} diff --git a/internal/web/templates/unlock.html b/internal/web/templates/unlock.html index 24182ce..f869088 100644 --- a/internal/web/templates/unlock.html +++ b/internal/web/templates/unlock.html @@ -18,12 +18,5 @@
Created {{.CreatedAgo}}
- + {{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index b3a8166..074d183 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -383,8 +383,12 @@ func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} } // #59: security headers for rendered HTML pages. Applied wherever the // response is text/html (page templates and the inline can page); JSON API -// responses and /raw content pass through untouched. script-src allows -// 'unsafe-inline' because the page templates carry inline scripts; CSP +// responses and /raw content pass through untouched. +// #139: script-src and style-src no longer allow 'unsafe-inline'. All +// previously-inline scripts moved to external files under static/ (page data +// reaches them via data-* attributes on the script tags), inline style +// attributes became CSS classes, and JS sets swatch colors via CSSOM. The +// img-src data: allowance stays: SVG data-URI backgrounds in app.css need it. // default-src 'self' still blocks external content and object/frame embeds, // and frame-ancestors 'none' closes the clickjacking gap flagged in the #34 // pentest. Runs after the handler so the Content-Type is already set. @@ -396,7 +400,7 @@ func SecurityHeaders(next http.Handler) http.Handler { // is harmless and arguably desirable. h := w.Header() h.Set("Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'") + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Content-Type-Options", "nosniff") next.ServeHTTP(w, r) From c4d55cb196ca9f26776ca08e283082f1933c1e3f Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 09:12:50 -0500 Subject: [PATCH 24/73] Fix #142: break long unbroken paste titles instead of forcing horizontal scroll Add word-break: normal; overflow-wrap: anywhere to .paste-title-bar h1 so a >120-char unbroken title wraps inside the title card at mobile width instead of producing an internal + page-level horizontal scrollbar. --- internal/web/static/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index b001e3f..c84a691 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -210,7 +210,7 @@ body { .slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: var(--radius-sm); } .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; } +.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; } .stats-head { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; 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); } From 0787871f2b6d61197816813604d9b48fd02f72b6 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 09:33:49 -0500 Subject: [PATCH 25/73] Fix #163: remove dead iscan/createCan path breaking /new Create --- internal/web/static/new.js | 57 -------------------------------------- 1 file changed, 57 deletions(-) diff --git a/internal/web/static/new.js b/internal/web/static/new.js index 16bbd47..6e81f19 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -151,8 +151,6 @@ $('reguess').addEventListener('click', guessLang); content.addEventListener('paste', () => setTimeout(guessLang, 0)); async function create() { - // #4: can mode — bundle the editor + extra items into a can via multipart - if ($('iscan').checked) return createCan(); // #38: file attached -> file paste (1 file = 1 paste; text is ignored) if (attachedFile) return createFilePaste(); @@ -330,61 +328,6 @@ async function createFilePaste() { finishCreate(data); } -// #4: can creation — POST multipart to /api/pastes/can. The main editor is -// the first item; each extra can-item row is another text item. -async function createCan() { - const items = []; - if (content.value.trim()) { - items.push({title: $('title').value || 'main', content: content.value, language: $('language').value || ''}); - } - document.querySelectorAll('#can-items .can-item-row').forEach(row => { - const t = row.querySelector('.can-item-title').value.trim(); - const c = row.querySelector('.can-item-content').value; - if (c.trim()) items.push({title: t || ('item-' + (items.length + 1)), content: c}); - }); - if (!items.length) { toast('Nothing to put in the can', 'error'); return; } - - const fd = new FormData(); - fd.append('title', $('title').value || 'Untitled can'); - fd.append('json_items', JSON.stringify(items)); - if ($('haspw').checked) fd.append('password', $('password').value); - if ($('unlisted').checked) fd.append('visibility', 'unlisted'); - const exp = document.querySelector('input[name="exp"]:checked').value; - if (exp === 'custom') { - const dur = composeCustomExpiry(); - if (dur === null) { toast('Check the custom expiry', 'error'); return; } - if (dur) fd.append('expires_in', dur); - } else if (exp) { - fd.append('expires_in', exp); - } - if ($('custom').value.trim()) fd.append('custom_slug', $('custom').value.trim()); - - const res = await fetch('/api/pastes/can', {method: 'POST', body: fd}); - const data = await res.json(); - if (!res.ok) { - showResult(friendlyError(data), 'err'); - toast('Can create failed', 'error'); - return; - } - const url = location.origin + data.url; - showResult('' + url + ' ', 'ok'); - const copyBtn = document.getElementById('result-copy'); - copyBtn.addEventListener('click', () => { - try { - navigator.clipboard.writeText(url); - copyBtn.classList.add('ok'); - copyBtn.textContent = 'Success!'; - setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000); - } catch(e) { toast('Copy failed', 'error'); } - }); - // password-protected can: unlock now with the password we already have (#26 parity) - if ($('haspw').checked && data.id) { - const pd = new FormData(); - pd.append('password', $('password').value); - try { await fetch('/can/' + data.id, {method: 'POST', body: pd}); } catch(e) {} - } - location.href = data.url; -} // reset stale result state when returning via Back (bfcache) (#28) window.addEventListener('pageshow', e => { if (!e.persisted) return; From 317ed2137acef0a8e957d2892ce23edb3a43f5e8 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 09:41:45 -0500 Subject: [PATCH 26/73] Settings: more padding between sections (#161) Add margin under the dark mode toggle and between section headings (theme/editor) on /settings via CSS classes, no inline styles (CSP). --- internal/web/static/app.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index c84a691..d95d5e0 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -248,6 +248,11 @@ body { .settings-section-title { color: var(--fg); font-size: 24px; margin-bottom: 6px; } /* theme switcher cards (#100) */ .theme-grid { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; } + +/* #161: more breathing room between settings sections */ +#settings-dark-toggle { margin-bottom: 20px; } +.settings-body h3 { margin-top: 28px; } +.settings-body .theme-grid { margin-bottom: 8px; } .theme-card { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; padding: 14px 16px; min-width: 180px; From 16cad8796fbe3f29f55ed5f2a12e56e8e3f89236 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 09:02:16 -0500 Subject: [PATCH 27/73] new paste tab: rework number box submenus (#157) Give the custom expiry and readable N times submenus a shared boxed container with proper padding, indent them under their parent option, and style the number inputs and unit select with palette vars, the existing radius scale, and the select chevron. No em dashes, no hard coded colors in new rules. --- internal/web/static/app.css | 33 +++++++++++++++++++++++++++++++++ internal/web/templates/new.html | 22 ++++++++++++---------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index c84a691..9ffc985 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -398,6 +398,39 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami /* protection section rhythm (#20) */ .protect { display: flex; flex-direction: column; gap: 2px; } .protect .pw-row { padding: 2px 8px 4px; } + +/* submenu number boxes (#157): styled number inputs + unit selects, + padded + indented to line up with parent option labels */ +.submenu { + margin: 6px 0 4px 8px; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.exp-group { display: flex; align-items: center; gap: 8px; } +.num-input { + width: 72px; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface-2); color: var(--fg); + font: inherit; font-size: 21.6px; padding: 6px 10px; outline: none; + -moz-appearance: textfield; appearance: textfield; +} +.num-input::-webkit-outer-spin-button, +.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.num-input:focus { border-color: var(--accent); } +.unit-select { + border: 1px solid var(--border); border-radius: var(--radius); + background-color: var(--surface-2); color: var(--muted-fg); + font: inherit; font-size: 21.6px; padding: 6px 28px 6px 10px; + cursor: pointer; outline: none; + appearance: none; -webkit-appearance: none; + background-image: var(--chevron); + background-repeat: no-repeat; background-position: right 10px center; +} +.unit-select:focus { border-color: var(--accent); } +.readable-group { gap: 8px; } +.readable-label { font-size: 19px; color: var(--muted-fg); white-space: nowrap; } .pw-field { display: flex; align-items: center; gap: 2px; width: 100%; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index c2a7b4c..877a5d3 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -46,15 +46,17 @@ - @@ -64,7 +66,7 @@ - + From 5fc5ae8aadabb61c025b71d899c9d3c433071c0e Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 10:13:42 -0500 Subject: [PATCH 28/73] editor head: wrap toggle state and pill highlight (#166) wrap button on the new paste page looked identical whether wrap was on or off because the btn base is already accent filled. give the toggle a neutral off state so the accent on state is visible, matching the icon button toggles on the paste view. also remove the accent border on the whole language settings pill when any child has focus. individual controls already show their own focus rings, so the pill highlight made no sense. --- internal/web/static/app.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 98b9b7e..2c5b7fe 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -150,8 +150,7 @@ body { } .editor-head-title { flex: 1; min-width: 0; } .editor-head-settings { flex: 0 0 fit-content; } -.editor-head-title:focus-within, -.editor-head-settings:focus-within { border-color: var(--accent); } +.editor-head-title:focus-within { border-color: var(--accent); } .editor-head input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 23.2px; flex: 1; } @@ -266,6 +265,10 @@ body { .theme-name { font-weight: 600; } .iconbtn:hover { color: var(--fg); border-color: var(--muted); } /* #130: line wrap toggle, accent when active like other toggles */ +/* #166: wrap toggle must show its state: neutral when off, accent when on. + The .btn base is accent filled, which made on and off look identical. */ +.btn-icon.wrap-toggle { background: var(--surface-2); color: var(--muted-fg); font-size: 21.6px; font-weight: inherit; border: 1px solid var(--border); } +.btn-icon.wrap-toggle:hover { color: var(--fg); border-color: var(--muted-fg); filter: none; } .iconbtn.wrap-toggle[aria-pressed="true"], .btn-icon.wrap-toggle[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); } /* #152: wrap ON must break long unbroken tokens mid-word and allow no horizontal scrolling */ From 9712926cf55b56c18ef638a23fc69e893fbae499 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 11:06:44 -0500 Subject: [PATCH 29/73] #173: render friendly 'Paste ID not found' UI instead of bare 404 Missing/expired/burned paste IDs and unknown routes now render the main UI (topbar + centered result card) with a 'Paste ID not found' message, returning HTTP 404 status for correctness. --- internal/api/server.go | 9 ++++-- internal/web/templates/notfound.html | 13 +++++++++ internal/web/web.go | 42 +++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 internal/web/templates/notfound.html diff --git a/internal/api/server.go b/internal/api/server.go index cf2aaf7..dfe1ac4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -129,9 +129,12 @@ func (a *apiServer) routes() http.Handler { r.Get("/{id}", a.handlePasteView) r.Post("/{id}", a.handlePasteView) - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - writeErr(w, 404, "not found") - }) +// #173: missing paste URLs render the main UI with a friendly not-found +// message instead of a bare JSON 404. Known routes (above) handle real pages; +// anything else is a nonexistent paste ID or typo. +r.NotFound(func(w http.ResponseWriter, r *http.Request) { + a.webHandlers().HandleNotFoundPage(w, r) +}) return r } diff --git a/internal/web/templates/notfound.html b/internal/web/templates/notfound.html new file mode 100644 index 0000000..eda487f --- /dev/null +++ b/internal/web/templates/notfound.html @@ -0,0 +1,13 @@ +{{template "head" .}} +{{template "topbar" .}} +
+
+
+
+

Paste ID not found

+

The paste /{{.ID}} does not exist, has expired, or was burned.

+ Create a new paste +
+
+
+{{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index 074d183..b502a4d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -104,6 +104,13 @@ func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) { } } +// RenderNotFoundPage is the exported not-found renderer used by the api +// package (#173): the router's NotFound handler renders the main UI with a +// friendly "Paste ID not found" message, still with HTTP 404. +func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) { + h.renderNotFound(w, r, r.URL.Path) +} + // RenderPage is the exported wrapper used by the api package (#4 can pages). func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) { h.renderPage(w, name, data) @@ -200,6 +207,30 @@ func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) { w.Write([]byte(`{"error":"rate limit exceeded"}`)) } +// renderNotFound serves the friendly not-found page (#173): the main UI +// chrome (topbar, centered card) with a "Paste ID not found" message in the +// result card, instead of a bare text 404. Still returns HTTP 404 so +// crawlers/validators see the correct status. +func (h *Handlers) renderNotFound(w http.ResponseWriter, r *http.Request, id string) { + h.renderPageStatus(w, "notfound.html", http.StatusNotFound, map[string]any{"Page": "notfound", "ID": id}) +} + +// HandleNotFoundPage serves the friendly not-found page for unknown routes +// (#173): main UI chrome with a "Paste ID not found" message. Called from the +// chi NotFound handler in the api package. +func (h *Handlers) HandleNotFoundPage(w http.ResponseWriter, r *http.Request) { + h.renderNotFound(w, r, r.URL.Path) +} + +// renderPageStatus renders a template with an explicit HTTP status code. +func (h *Handlers) renderPageStatus(w http.ResponseWriter, name string, status int, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, "template error: "+err.Error(), 500) + } +} + func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) { lines := strings.Count(row.Content, "\n") + 1 gutter := "" @@ -262,11 +293,14 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { return } if row == nil { - http.NotFound(w, r) + // #173: a missing paste ID gets the main UI with a friendly message, + // not a bare text 404 page. + h.renderNotFound(w, r, id) return } if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() { - http.Error(w, "paste expired", 404) + // #173: expired pastes render the same friendly not-found UI. + h.renderNotFound(w, r, id) return } if row.PasswordHash.Valid { @@ -333,8 +367,8 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { // Just-created first render does not count as a read for the creator. if !justCreated { rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin()) - if !admitted { // #58: lost the burn claim; do not render content - http.NotFound(w, r) + if !admitted { // #58: lost the burn claim; #173: friendly not-found UI + h.renderNotFound(w, r, row.ID) return } h.renderPaste(w, row, false, "", rem) From 122c1e14f43a6062a1eb07a67422f5354e29e889 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 11:06:52 -0500 Subject: [PATCH 30/73] web: friendly Paste ID not found page for missing pastes (#173) Missing or expired paste IDs (and unknown routes) now render the main UI chrome with a 'Paste ID not found' message in a color-coded result card instead of a bare text/JSON 404. HTTP status stays 404. No inline scripts or styles; new CSS uses existing --err token and pill radii. --- internal/api/main_test.go | 24 ++++++++++++++++++++++++ internal/web/static/app.css | 3 +++ 2 files changed, 27 insertions(+) diff --git a/internal/api/main_test.go b/internal/api/main_test.go index e3548ce..ab41402 100644 --- a/internal/api/main_test.go +++ b/internal/api/main_test.go @@ -299,3 +299,27 @@ func TestNotFound(t *testing.T) { t.Fatalf("expected 404, got %d", rec.Code) } } + +// #173: a missing paste ID on the UI route (/p/{id}, i.e. /{id} HTML view) +// should render the main UI page with a friendly "Paste ID not found" +// message, not a bare text 404. Status stays 404. +func TestPasteViewNotFoundFriendly(t *testing.T) { + s := testServer(t) + h := s.routes() + req := httptest.NewRequest("GET", "/zzzzzz", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 status, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Paste ID not found") { + t.Fatalf("expected friendly message in body, got: %.200s", body) + } + if !strings.Contains(body, "