230 Commits
Author SHA1 Message Date
fen feb36fc501 Merge pull request '#296 fix attempt 3: shrink URL column, plain-text URL values' (#301) from fix-296-r3 into dev
CI / test (push) Successful in 26s
CI / docker (push) Successful in 41s
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-18 16:23:38 +00:00
fen ce14c6b713 #296 r3: shrink URL column, render URL values as plain text
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
Owner override postdating PR #300 QA pass:
- .col-url 340 -> 230px; reclaim slack from history-only columns (Views 96->90,
  Created 190->150, ID 190->120) so the table fits the .float pane with no
  horizontal scroll at 1400x900 and the ID column is fully visible.
- URL anchors drop the shared .slug pill (background/padding/radius/mono font)
  and render plain, styled via td.dim exactly like the none case; links stay
  anchors with accent hover. Ellipsis kept for genuinely long slugs.

Verified by CDP probe on a local build at 1400x900 and 375x812 on /public and
/mine (/saved): column order, table fits pane (scrollWidth == clientWidth),
20-char slug fully visible, min Created-to-URL content gap 77px, sort arrows
intact 6px right of labels, no CSP/console violations. go build/test pass.
2026-09-18 11:15:48 -05:00
fen d87b93819b Merge pull request 'URL column: restore position 6 and widen (fix attempt 2)' (#300) from fix-296-r2 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 32s
2026-09-18 14:05:02 +00:00
fen 73c06b864a #296 r2: restore URL column to position 6 and widen to 340px
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
Reverts PR #297's column reorder (owner override): URL returns to position
6 in both list tables (Paste, Type, Size, Views, Created, URL, ID; /mine
without Views), data-sort attrs and td cells move back together. Keeps
#297's non-position improvements: /mine URL uses the shared .col-url class
instead of reusing col-e, and mine's ID column returns to .col-f (100px).

Widens .col-url 150px -> 340px so a ~20-char slug displays fully at
1400x900; genuinely long custom slugs keep text ellipsis. No mobile media
query needed: <=640px already scrolls the table horizontally (min-width
720px), verified at 375x812.

Verified via CDP probe at 1400x900 and 375x812 on /history and /mine:
column order, colgroup/header alignment, sort arrow right of label, no
other column clipped, no CSP changes (no inline styles added).
2026-09-18 09:01:03 -05:00
fen a3abf7c73e Merge pull request '#296: move URL column to position 2 (after Paste) in list tables' (#297) from fix-296 into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 32s
2026-09-18 03:56:28 +00:00
fen 6e85a7686d #296: move URL column to position 2 (right after Paste) in history and mine tables
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-17 22:54:19 -05:00
fen 2046e45601 Merge pull request '#294: switching to midnight theme forces dark mode' (#295) from fix-294 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 34s
2026-09-18 03:14:20 +00:00
fen 105ffc9f17 #294: resolve midnight theme variant via lightPreset/darkPreset (midnight is dark-first)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-17 22:12:25 -05:00
fen 4fa60ea7b9 Merge pull request '#292: highlight paste name uniformly on row hover in list tables' (#293) from fix-292 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 35s
2026-09-18 02:58:13 +00:00
fen e87bdb221e #292: highlight paste name uniformly on row hover in list tables
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-17 21:45:44 -05:00
fen aa72b4b38b Merge pull request '#280: remove PALETTE_TRUSTED_IP_HEADER, rate limits key on peer address only' (#291) from fix-280-r2 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 42s
2026-09-18 01:53:23 +00:00
fen aac1725c25 #280: remove PALETTE_TRUSTED_IP_HEADER, key rate limits on peer address only
CI / test (pull_request) Successful in 31s
CI / docker (pull_request) Skipped
Owner follow-up to the #280 fix (PR #284): the trusted-header env var is
gone. clientIP() now uses the peer address exclusively and ignores all
client-supplied IP headers; the env var row is removed from the README.
2026-09-17 20:48:52 -05:00
fen 880f3cd958 Merge pull request 'Sync main into dev (owner README edit #279)' (#290) from dev-main-sync into dev
CI / test (push) Successful in 26s
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
CI / docker (push) Successful in 46s
2026-09-18 01:48:43 +00:00
fen 1149989d07 Merge pull request 'Release v0.5.0: dev -> main' (#288) from dev into main
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
# Conflicts:
#	README.md
2026-09-17 20:46:42 -05:00
fen ed435c5e13 Merge pull request 'Fix #282: jumpnav visible on mobile initial load' (#285) from fix-282 into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 46s
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-18 01:35:15 +00:00
fen c190dd9ea6 Merge pull request '#281: /raw/{id} returns empty body for non-image attachment pastes (Fix attempt 1)' (#286) from fix-281 into dev
CI / test (push) Successful in 32s
CI / docker (push) Successful in 43s
2026-09-18 01:33:45 +00:00
fen d4da322031 Merge pull request 'Release v0.5.0: README refresh' (#278) from release-readme-v0.5.0 into dev
CI / test (push) Successful in 30s
CI / docker (push) Successful in 53s
2026-09-18 01:33:27 +00:00
fen eca8533d70 Merge pull request 'Fix #280: rate limiter keys buckets on peer address, not client-controlled X-Forwarded-For' (#284) from fix-280 into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 47s
2026-09-18 01:33:04 +00:00
fen 5770b2f88e Merge pull request 'Sync main into dev before v0.5.0 release' (#277) from dev-sync into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 52s
2026-09-18 01:32:55 +00:00
fen 8901a3c82c #281: /raw streams attachment blob for all attachment mimes
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
handleRaw only streamed the blob behind an isImageMime gate (#221), so
non-image attachment pastes fell through to empty row.Content and /raw
served 0 bytes. Serve the blob for every attachment mime, passing the
sniffed mime through serveContentType so active-content types (html,
svg, xml) still serve as text/plain per the #34 rule. Regression tests
cover text and html attachments (size, Content-Type, byte equality).
2026-09-17 20:31:57 -05:00
fen 3460d54fce Fix #282: re-evaluate jumpnav visibility after layout settles on load
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
The initial refresh() ran before the mobile layout settled (media queries,
fonts, async highlighting) and under-measured the content, leaving #jumpnav
hidden on long pastes at 375x812 until a resize event. Re-check after a
double rAF, on window load, after 300ms, and via a ResizeObserver on
document.body for late content growth. Editor textarea scroller unchanged.
2026-09-17 20:30:04 -05:00
fen f63efc6d88 Fix rate limiter bypass via client-controlled X-Forwarded-For (#280)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
clientIP() keyed rate-limit buckets on the rightmost X-Forwarded-For
entry, assuming traefik appends the real client IP. The deployed ingress
does not rewrite XFF, so rotating the header gave a fresh bucket per
request (pentest H1: 8 creates with rotating XFF -> 6x201).

Now the bucket keys on the actual peer address (RemoteAddr) by default;
every client-supplied IP header is ignored. Deployments whose ingress
overwrites a client-IP header can opt in via PALETTE_TRUSTED_IP_HEADER
(e.g. CF-Connecting-IP behind Cloudflare) to restore per-client limits.

Adds tests: rotating XFF no longer resets the bucket; the trusted header
is honored only when explicitly configured.
2026-09-17 20:29:40 -05:00
poslop bc52f0a608 Merge pull request 'Update README.md' (#279) from poslop-patch-1 into main
CI / test (push) Successful in 24s
CI / docker (push) Skipped
Reviewed-on: #279
2026-09-18 01:21:36 +00:00
poslop 84d19556fc Update README.md
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
2026-09-18 01:21:27 +00:00
fen f3fe2335d4 Release v0.5.0: README refresh
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
- Add code-viewer polish line to the feature list (pinned gutter sized to
  the widest number, line wrap, jump buttons, theme-aware scrollbars)
- Extend mobile preview links (editor, dark and light paste views)
- Clarify PALETTE_MAX_ITEM covers can items and file attachments
- Document PALETTE_DEFAULT_DARK in docker-compose.yml so the compose file
  really covers every env var
2026-09-17 20:10:25 -05:00
fen fe5960f803 Merge remote-tracking branch 'origin/main' into dev-sync
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-17 19:55:30 -05:00
fen 70b06db192 Merge pull request '#274: align editor line numbers with wrapped text rows' (#276) from fix-274 into dev
CI / test (push) Successful in 24s
CI / docker (push) Successful in 42s
2026-09-17 23:56:34 +00:00
fen 7fdb3ee61c #274: align editor line numbers with wrapped text rows
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
With wrap on, a logical line occupies several visual rows in the textarea
but the gutter showed one number per logical line, so every number after
the first wrapped line drifted off its text (the paste view fixed this in
#167; the editor gutter did not). Measure the wrapped row count per logical
line with a hidden mirror div sharing the editor's font and wrapping rules,
and render one .gutline block per visual row with the number on the first
row of its logical line. Re-measure on input, wrap toggle and resize.
Verified: gutter scrollHeight == textarea scrollHeight with zero diff at
1400x900 and 375x812, wrap on and off.
2026-09-17 18:50:08 -05:00
fen ad397b80d0 Merge pull request '#273: theme-aware scrollbars' (#275) from fix-273 into dev
CI / test (push) Successful in 24s
CI / docker (push) Successful in 36s
2026-09-17 23:45:41 +00:00
fen 714b4691e9 #273: theme-aware scrollbars via scrollbar-width/scrollbar-color + webkit fallbacks
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-17 18:44:01 -05:00
fen ca0e51192a Merge pull request '#260 (fix attempt 2): static-width copy feedback via .swapbtn' (#271) from fix-260-r2 into dev
CI / test (push) Successful in 23s
CI / docker (push) Successful in 32s
2026-09-17 22:14:36 +00:00
fen 75264ee9f4 #260 (fix attempt 2): static-width copy feedback via .swapbtn
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-17 17:13:28 -05:00
fen 2a55f50d87 Merge pull request '#260: static button width during copy feedback (fix attempt 1)' (#268) from fix-260 into dev
CI / test (push) Successful in 23s
CI / docker (push) Successful in 32s
2026-09-17 22:07:44 +00:00
fen b2fd5d548d Merge origin/dev into fix-260 for QA
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-17 17:06:38 -05:00
fen c9ad84f523 Merge pull request '#261: keep line number gutter visible during horizontal scroll' (#265) from fix-261 into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 34s
2026-09-17 22:01:38 +00:00
fen 25906612f6 Merge origin/dev into fix-261 for QA
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-17 17:00:43 -05:00
fen 928907dc9d Merge pull request '#255: widen history URL column' (#258) from fix-255 into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 32s
2026-09-17 21:54:47 +00:00
fen 9fe64e0928 Merge origin/dev into fix-255 for QA (rebase onto current dev)
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-17 16:53:27 -05:00
fen 60784386ab Merge pull request '#267: jump to top and bottom buttons for long pastes and editor' (#269) from fix-267 into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 34s
2026-09-17 19:51:35 +00:00
fen a4118b92a9 #267: jump to top/bottom buttons for long pastes and the editor
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
Fixed-position Top/Bottom pills appear only when content exceeds 2x
viewport height (window scroll on paste view, textarea scroll on /new).
New static/jump.js drives them; markup added to paste.html and new.html.
2026-09-17 14:49:02 -05:00
fen 11cf7428eb #260: pin button width during copy feedback so neighbors never jump
CI / test (pull_request) Successful in 22s
CI / docker (pull_request) Skipped
2026-09-17 14:47:34 -05:00
fen 1872dac8b4 Merge pull request '#257: size line number gutter to widest number' (#266) from fix-257 into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 33s
2026-09-17 19:38:56 +00:00
fen 4050f1362e #257: size the paste gutter to the widest line number
CI / test (pull_request) Successful in 22s
CI / docker (pull_request) Skipped
The paste gutter was pinned to a fixed 3ch width. With box-sizing:
border-box that leaves only ~19px of content after the 10px+10px side
padding, so 2+ digit line numbers overflow right into the code text
(owner-visible from line 10, worst at 100+).

paste-lines.js now sets the gutter width to calc(Nch + 20px), where N is
the digit count of the highest line number, via CSSOM (CSP forbids
inline style attributes). Numbers were already right-aligned; the column
now matches the width of the biggest number. The width is only written
when it changes, so the resize-observer/renumber loop keeps a stable
fixed point.
2026-09-17 14:37:27 -05:00
fen f94a813d79 #261: keep line number gutter visible during horizontal scroll - move the horizontal scroll from the .code flex container to the codebody so the pinned gutter stays in view
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-17 14:35:52 -05:00
fen cdf9136866 Merge pull request 'Fix attempt 1: sync editor gutter with textarea scroll' (#263) from fix-259 into dev
CI / test (push) Successful in 23s
CI / docker (push) Successful in 41s
2026-09-17 19:35:13 +00:00
fen 4abc8ec202 Merge pull request '#260: replace Success! feedback with a compact checkmark' (#262) from fix-260 into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 34s
2026-09-17 19:34:34 +00:00
fen ca54653a11 Merge pull request '#256: rename page URLs to match nav labels' (#264) from fix-256 into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 36s
2026-09-17 19:32:27 +00:00
fen c91d0e53ca #256: rename page URLs to match nav labels (/public, /saved)
CI / test (pull_request) Successful in 22s
CI / docker (pull_request) Skipped
2026-09-17 14:30:23 -05:00
fen ee0cd7dcbd new page: sync editor gutter with textarea scroll (#259)
CI / test (pull_request) Successful in 22s
CI / docker (pull_request) Skipped
2026-09-17 14:30:03 -05:00
fen a4e1abfaae #260: replace Success! feedback text with a checkmark so buttons do not resize
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-17 14:29:02 -05:00
fen d25e20733e #255: widen history URL column with dedicated col-url class
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-17 14:24:00 -05:00
fen ca785f5648 Merge pull request '#249: hide code block for all attachment pastes' (#254) from fix-249 into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 40s
2026-09-17 19:17:53 +00:00
fen 91757752b8 Merge pull request 'Cap attachment filenames at 128 chars server-side' (#253) from fix-248 into dev
CI / test (push) Successful in 20s
CI / docker (push) Successful in 40s
2026-09-17 19:16:33 +00:00
fen bb2c5e200c #249: hide code block for all attachment pastes, not only images
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
2026-09-17 14:16:03 -05:00
fen 8474b8eb02 Cap attachment filenames at 128 chars server-side
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
A 250-char multipart filename was accepted and echoed verbatim in
Content-Disposition. SanitizeFilename already truncates; lower the cap
from 255 to 128 so DB rows and header echoes stay bounded (#248).
2026-09-17 14:15:05 -05:00
poslop e81f825ab8 Merge pull request 'Update README.md' (#252) from poslop-adjust-images into main
CI / test (push) Successful in 19s
CI / docker (push) Skipped
Reviewed-on: #252
2026-09-17 19:07:11 +00:00
poslop 3d50a264ed Update README.md
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
2026-09-17 19:02:36 +00:00
fen 522e016a29 Merge pull request 'Release v0.4.0: dev -> main' (#251) from dev into main
CI / test (push) Successful in 19s
CI / docker (push) Successful in 36s
2026-09-17 15:35:20 +00:00
fen cf6e52bcc5 Merge pull request 'Reserve mine/unlock/guess/f custom slugs' (#247) from fix-reserved-slugs into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 48s
CI / test (pull_request) Successful in 19s
CI / docker (pull_request) Skipped
2026-09-17 15:32:17 +00:00
fen 3a8f528693 Merge pull request 'v0.4.0 release prep: README + screenshots (Ref #160)' (#246) from release-readme-160 into dev
CI / test (push) Successful in 20s
CI / docker (push) Successful in 39s
2026-09-17 15:31:52 +00:00
fen ed22579bed #243 pentest follow-up: reserve mine/unlock/guess/f custom slugs
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
2026-09-17 10:31:06 -05:00
fen 7ee3ffbfc9 v0.4.0 release prep: README refresh + new wiki screenshots (Ref #160)
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-17 10:25:20 -05:00
fen 53e7a97c44 Merge pull request '#243 fix attempt 2: button capitalization + link button label persistence' (#245) from fix-243-r2 into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 46s
2026-09-17 14:50:10 +00:00
fen e7ecb9789b #243 fix attempt 2: capitalize action buttons; link button keeps its label after copy feedback
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
2026-09-16 15:56:15 -05:00
fen 3c5ccfb787 Merge pull request '#243: copy button should copy the link' (#244) from fix-243 into dev
CI / test (push) Successful in 19s
CI / docker (push) Successful in 47s
2026-09-16 20:44:56 +00:00
fen 1f745b6e59 #243: copy button copies the paste link; content copy kept on separate button
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-16 15:40:39 -05:00
fen a9f94ce806 Merge pull request '#233: file attachment title takes the file name' (#236) from fix-233 into dev
CI / test (push) Successful in 50s
CI / docker (push) Successful in 44s
2026-09-10 23:39:44 +00:00
fen dbcdd1b7cd Merge pull request '#240: burn-after-read readable box padding/indent consistency' (#242) from fix-240 into dev
CI / test (push) Successful in 43s
CI / docker (push) Successful in 33s
2026-09-10 23:38:09 +00:00
fen c89d064eb8 #240: indent burn-after-read readable box under the option label text and match row padding rhythm
CI / test (pull_request) Successful in 44s
CI / docker (pull_request) Skipped
2026-09-10 18:09:00 -05:00
fen 9c0bd7abb9 Merge pull request '#240: burn-after-read submenu padding inconsistent with UI' (#241) from fix-240 into dev
CI / test (push) Successful in 43s
CI / docker (push) Successful in 43s
2026-09-10 23:00:32 +00:00
fen f25ed8bc5d #240: keep submenu padding on burn-after-read row
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
2026-09-10 17:58:21 -05:00
fen 9490b8d25a Merge pull request '#238: page 2 shows in pagination window when not adjacent' (#239) from fix-238 into dev
CI / test (push) Successful in 43s
CI / docker (push) Successful in 40s
2026-09-10 22:42:25 +00:00
fen b840bffeba #238: drop hardcoded page 2 from pagination window
CI / test (pull_request) Successful in 44s
CI / docker (pull_request) Skipped
2026-09-10 17:29:28 -05:00
fen 5075374abe Merge pull request '#235: rename Language column to Type; attachments show file extension' (#237) from fix-235 into dev
CI / test (push) Successful in 43s
CI / docker (push) Successful in 37s
2026-09-10 22:14:57 +00:00
fen 0008b8ce0c #235: rename Language column to Type; attachment pastes show file extension
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
- history/mine table column 'Language' -> 'Type' (data-sort key 'type')
- /api/public and /api/mine rows gain a 'type' field: file extension for
  attachment pastes, stored language otherwise (default 'text')
- paste page stats label 'Language' -> 'Type'; summary line uses the same
  label; get-paste JSON gains 'type'
- store list queries LEFT JOIN attachments to expose the filename
- table.js sorting accepts the 'type' key
2026-09-10 17:13:37 -05:00
fen 1dd8a3b28a Merge pull request '#232: remove duplicate filename under attachment pill, widen pill' (#234) from fix-232 into dev
CI / test (push) Successful in 46s
CI / docker (push) Successful in 41s
2026-09-10 22:06:25 +00:00
fen 9a5eaed00b #232: file pill shows filename twice on non-image files; drop duplicate, let long names truncate wider and reveal on hover
CI / test (pull_request) Successful in 44s
CI / docker (pull_request) Skipped
2026-09-10 17:05:18 -05:00
fen 785fafcaf9 #233: file attachment title takes the file name, fallback date.ext
CI / test (pull_request) Successful in 44s
CI / docker (pull_request) Skipped
2026-09-10 17:05:08 -05:00
fen ca59c19fa4 fix-232: drop attachment-preview img for non-image files (alt text painted duplicate filename); widen chip name max-width to 560px
CI / test (pull_request) Successful in 42s
CI / docker (pull_request) Skipped
2026-09-10 17:02:54 -05:00
fen e059ca1554 Merge pull request '#229: padding between image and pill body border' (#230) from fix-229 into dev
CI / test (push) Successful in 42s
CI / docker (push) Successful in 52s
2026-09-10 19:55:58 +00:00
fen 86eb821780 #229: padding between image and pill border, drop corner-clipped inner border
CI / test (pull_request) Successful in 46s
CI / docker (pull_request) Skipped
2026-09-10 14:54:28 -05:00
fen e589428315 Merge pull request 'Fix #221 follow-up: summary pill size for attachment pastes' (#228) from fix-221-r2 into dev
CI / test (push) Successful in 42s
CI / docker (push) Successful in 42s
2026-09-10 19:33:51 +00:00
fen d18bbb7064 #221: summary pill size must come from the attachment blob for file pastes
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
The merged #224 fix covered the details grid but left the summary
pill using humanSize(len(row.Content)), which reads 0 for attachment
pastes (text content is empty). Use the attachment size there too.
2026-09-10 14:31:56 -05:00
fen cb58a51bab Merge pull request 'fix #221: scale image pastes, clean link pill, raw image view, accurate size' (#224) from fix-221 into dev
CI / test (push) Successful in 41s
CI / docker (push) Successful in 40s
2026-09-10 19:21:22 +00:00
fen 98ea7eefa9 fix #221: scale image pastes, drop text box, fix raw view and size
CI / test (pull_request) Successful in 45s
CI / docker (pull_request) Skipped
- image pastes render the image scaled to fit the viewer box (aspect
  ratio preserved, max-height 70vh), no text/code box below it
- link pill moved under the image as a small inline chip
- /raw serves image attachment bytes as an image instead of empty text
- view details size reports the actual attachment file size
2026-09-10 14:19:07 -05:00
fen 44cff7e718 Merge pull request 'Fix details pill border clipped thin at corners' (#223) from fix-222 into dev
CI / test (push) Successful in 44s
CI / docker (push) Successful in 52s
2026-09-10 19:18:27 +00:00
fen fa702eb8b1 Fix details pill border clipped thin at corners (#222)
CI / test (pull_request) Successful in 42s
CI / docker (pull_request) Skipped
The details pill drew its own 1px border with a 10px radius inside .float, which draws the real border at 20px radius and clips with overflow:hidden. The parent's looser curve clipped the pill's tighter corner curve, making the border appear thinner/cut at the corners, in both collapsed and expanded states. Drop the pill's own border/radius so .float is the single border surface, matching the other pills on the page.

Ref #222
2026-09-10 14:16:28 -05:00
fen 7c7e60375c Merge pull request #217 from fen/fix-209
CI / test (push) Successful in 41s
CI / docker (push) Successful in 37s
Fix #209 r2: arrows immediately right of the column label text
2026-09-10 13:27:49 -05:00
fen 700b8252f0 Merge pull request 'Fix #209 r2: sort arrows immediately right of label text' (#218) from fix-209 into dev
CI / test (push) Successful in 47s
CI / docker (push) Successful in 41s
2026-09-10 18:17:39 +00:00
fen 89a9c173ca Fix #209 r2: sort arrows immediately right of label text
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
2026-09-10 13:15:06 -05:00
fen 13884ca786 Merge pull request #215 from fix-205
CI / test (push) Successful in 46s
CI / docker (push) Successful in 42s
Fix #205: stop gutter drift from phantom newline rows
2026-09-10 18:15:03 +00:00
fen 7cffd21df9 Fix #209 r2: arrows immediately right of label text, fixed gap
CI / test (pull_request) Successful in 44s
CI / docker (pull_request) Skipped
Absolute right:8px pinned arrows to the column edge; inline span after
the label with margin-left:6px keeps a consistent gap from the text.
2026-09-10 13:14:28 -05:00
fen 5034f156ee Fix #205: strip whitespace-only text nodes between codeline blocks
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
paste-lines.js rebuilt the codebody with innerHTML but relied on the
server-side '\n' delimiters disappearing between the per-line blocks.
Under white-space: pre any whitespace-only text node that survives
(cached HTML, template drift) renders as a phantom row and drifts the
gutter numbers off their lines. splitLines() now strips whitespace-only
text nodes after rebuilding, keeping renumber() geometry intact.
2026-09-10 13:12:12 -05:00
fen 6951470bda Merge pull request 'Fix #207 r2: dark mode toggle left aligned per owner feedback' (#213) from fix-207-r2 into dev
CI / test (push) Successful in 41s
CI / docker (push) Successful in 43s
2026-09-10 18:08:45 +00:00
fen c320fa03c9 Fix #207 r2: dark mode toggle left aligned per owner feedback
CI / test (pull_request) Successful in 46s
CI / docker (pull_request) Skipped
2026-09-10 13:07:14 -05:00
fen 672288e60b Merge pull request 'Fix #209: sort arrows to the right of column labels' (#212) from fix-209 into dev
CI / test (push) Successful in 45s
CI / docker (push) Successful in 49s
2026-09-10 18:02:46 +00:00
fen 5039a76bec Fix #209: move sort arrows to the right of the column label
CI / test (pull_request) Successful in 45s
CI / docker (pull_request) Skipped
2026-09-10 13:02:22 -05:00
fen a49127eff2 Merge pull request 'Fix #210: saved pastes ID column / delete button overlap' (#211) from fix-210 into dev
CI / test (push) Successful in 40s
CI / docker (push) Successful in 40s
2026-09-10 18:00:42 +00:00
fen 8636a23f49 Fix #210: declare missing delete-button column in saved pastes table
CI / test (pull_request) Successful in 45s
CI / docker (pull_request) Skipped
/mine rows render 7 cells (ID + delete button) but the colgroup and
thead only declared 6 columns, so under table-layout:fixed the delete
button cell overlapped the ID column at narrower widths. Add a
col-del column + empty header th to match the row cells.
2026-09-10 12:56:10 -05:00
fen 348f792c73 Merge pull request 'Fix #169: persistent full-width divider between dropdown title and body' (#195) from fix-195 into dev
CI / test (push) Successful in 44s
CI / docker (push) Successful in 45s
2026-09-10 12:27:33 -05:00
fen 5c6cd40539 Merge pull request 'Fix #167 r2' from fix-167-r2 into dev
CI / test (push) Successful in 42s
CI / docker (push) Successful in 39s
2026-09-10 12:22:14 -05:00
fen ec791cc2db Fix #167 r2: measure-based gutter rows + fix undefined tops ref in renumber
CI / test (pull_request) Successful in 42s
CI / docker (pull_request) Skipped
QA of the #167 follow-up found two defects:
1) renumber() referenced an undefined 'tops' variable in the rAF
   re-verification pass, so any wrap toggle that reflowed a partially
   filled last visual row left stale (non-idempotent) numbering.
2) rows were derived from per-span heights while the code column also
   renders newline text nodes between .codeline blocks, so the gutter
   could drift from the code column's real visual rows.

renumber() now measures each .codeline's actual offsetTop and places
every number on the visual row its line starts on (a line wrapping to N
rows gets its number on the first of them), pads filler rows in between,
and self-corrects one frame later if reflow moves any line. Wrap-OFF
path stays unchanged ('1
2
3
4'). Verified in headless Chromium:
gutter/code row alignment exact at 1200px and 640px, toggle cycles
stable, no horizontal scroll, no CSP console errors; go build/test pass.
2026-09-10 12:19:44 -05:00
fen 23cf3151c4 Merge pull request 'Fix settings dark mode toggle and center it (#197)' (#199) from fix-197 into dev
CI / test (push) Successful in 42s
CI / docker (push) Successful in 50s
2026-09-10 17:16:38 +00:00
fen f1665f1b31 Merge fix-167 (PR #200) into dev
CI / test (push) Successful in 38s
CI / docker (push) Successful in 43s
Resolves #167

QA: fen
2026-09-10 12:14:21 -05:00
fen e1056df747 Fix #167: gutter alignment with wrap on
CI / test (pull_request) Successful in 46s
CI / docker (pull_request) Skipped
Rebuild renumber() so gutter numbers are placed by measured geometry:
each .gutline block is one visual row and each number is assigned to the
row whose top matches its .codeline's top, instead of counting rows per
line. This keeps numbers aligned with wrapped line starts regardless of
how many visual rows a line occupies. Wrap OFF path unchanged.
2026-09-10 12:12:31 -05:00
fen 5fdb34a059 Fix #167: align gutter numbers with actual rendered line rows (wrap ON)
CI / test (pull_request) Successful in 44s
CI / docker (pull_request) Skipped
paste-lines.js joined .codeline spans with '\n' text nodes that pre-wrap
renders as extra line boxes, and renumber() counted per-line span heights
(rounding) instead of real geometry, so gutter numbers drifted off their
line starts on wrapped lines. Fix: join spans with '' (blocks need no
newline text), and build one .gutline per visual row measured from the
code body's real height, placing each number at the visual row whose top
matches its .codeline's measured top. Wrap OFF path unchanged. Mobile
font-size rule added so gutter row pitch matches code rows at 375x812.
2026-09-10 12:12:00 -05:00
fen 734d656f0c Merge pull request 'Fix #194 r2: line-number gutter alignment in both wrap modes' (#198) from fix-194-r2 into dev
CI / test (push) Successful in 47s
CI / docker (push) Successful in 49s
2026-09-10 17:11:08 +00:00
fen 8042116a69 Fix settings dark mode toggle double-binding and center it (#197)
CI / test (pull_request) Successful in 50s
CI / docker (pull_request) Skipped
topbar.js binds click handlers to every .dark-toggle, including the
settings one; settings.js then also wired a delegation that re-clicked
the topbar button, so each click flipped dark mode twice (net no-op).
Mark buttons as wired in topbar.js and only add a fallback handler in
settings.js when topbar.js did not run.

CSS: center the toggle horizontally under the Settings title with auto
margins, matching .settings-body padding.

Closes #197
2026-09-10 12:10:00 -05:00
fen d77f1faf67 Merge pull request 'Fix #167 (r2): pin gutter width so wrapped-row renumbering stays aligned' (#192) from fix-167 into dev
CI / test (push) Successful in 46s
CI / docker (push) Successful in 47s
2026-09-10 17:09:56 +00:00
fen 2149fcc2e1 Fix #167: gutter numbers align with wrapped-line starts
CI / test (pull_request) Successful in 49s
CI / docker (pull_request) Skipped
- splitLines(): join .codeline blocks with "" not "\n" — newline text
  nodes under pre-wrap rendered an extra line box per logical line, so
  every gutter number drifted one row per line
- renumber(): geometry-driven placement — one gutline per visual row of
  the code body, each number assigned to the row matching its line's
  measured offsetTop; verified second pass re-runs if a partially-filled
  last wrapped row reflows after the gutter rebuild
- app.css: mobile media query gives .code .gutter the same 13px font as
  .code so gutter rows and code rows share one line box at 375px

Verified in local build with headless chromium (long unbroken token on
line 5, wrap ON): all numbers align with line starts, no misalignment,
no horizontal scroll at 1400x900 and 375x812.
2026-09-10 12:09:16 -05:00
fen e430eb57b7 Fix #167: renumber gutter to visual rows when line wrap is on
CI / test (pull_request) Successful in 39s
CI / docker (pull_request) Skipped
Split the paste codebody into per-line block spans (paste-lines.js) and
rebuild the gutter from each line's wrapped height so numbers count
visual rows, not logical lines. Pinned the gutter width (flex 0 0 auto,
3ch) so its row count no longer feeds back into the code column width.
ResizeObserver renumbers on wrap toggle / resize; wrap-off behavior is
unchanged (one number per newline).
2026-09-10 12:08:49 -05:00
fen 1f5e671a43 Fix #194 r2: align line-number gutter in both wrap modes
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
QA FAIL of PR #179 (verified on palette-dev 2026-09-10) found two defects:

1) Row accounting: splitLines() joined .codeline blocks with '\n' text
   nodes. Since dee062d made .codeline display:block, each interleaved
   newline text node rendered as its own extra visual row in the code
   column under pre-wrap, but renumber() counted only .codeline span
   heights, so the gutter had one fewer row per line break and numbers
   drifted off alignment on wrapped lines. Fix: join('') so blocks are
   adjacent with no phantom newline rows; blank source lines become
   empty blocks given a line box via .codeline:empty::before (ZWJ) in
   app.css so they still count as one row.

2) Inline gutter spans: .gutline spans were appended with no separators
   and .code .gutter .gutline { display: block } did not exist on the
   deployed build, so all numbers landed on one row. That rule is
   present in origin/dev (merged via #183); kept unchanged and verified
   by rendering in a local build.

Wrap OFF path unchanged: gutter still gets '1\n2\n3\n4' text and the
horizontal-scroll gutter behavior is untouched (no CSS changes to
scrolling; only a :empty line-box rule). CSP unchanged: no inline
styles/handlers, static JS only.
2026-09-10 12:07:42 -05:00
fen b603b29359 Merge pull request 'Fix #187: add favicon (paint palette)' (#196) from fix-187-favicon into dev
CI / test (push) Successful in 48s
CI / docker (push) Successful in 48s
2026-09-10 17:03:32 +00:00
fen 2a21251c47 Fix #187: add favicon (paint palette)
CI / test (pull_request) Successful in 39s
CI / docker (pull_request) Skipped
2026-09-10 12:01:54 -05:00
fen 179eb17da9 Fix #169: persistent full-width divider between dropdown title and body
CI / test (pull_request) Successful in 46s
CI / docker (pull_request) Skipped
The can page 'Show content' dropdown had no divider between the summary
title and the body; the only line-like artifact appeared hover-dependent
(the stats header hover background edge). Per the owner follow-up, the
divider between a dropdown title and its body must be persistent (not
hover dependent) and extend the full width of the card left and right.

can page: .can-item-body[open] summary now carries a permanent 1px
var(--border) bottom rule spanning the full card width via negative
margins matching the card padding, plus matching bottom padding.
2026-09-10 11:58:41 -05:00
fen bb72e09f8f Merge pull request 'Fix #184: file upload replaces the main editing area on /new' (#189) from fix-184 into dev
CI / test (push) Successful in 43s
CI / docker (push) Successful in 44s
2026-09-10 16:55:32 +00:00
fen 6c1bfaf15e Fix #184: file upload replaces the main editing area on /new
CI / test (pull_request) Successful in 48s
CI / docker (pull_request) Skipped
Image files render fitted into the editor area (editor and gutter
hidden); the title auto-fills with the file name. Text files always
load their content into the editor, replacing any text already there,
instead of only filling an empty editor. Title auto-fill still only
applies when the title is blank; a typed title is never overwritten.
Drawing and cropping tools are noted in the issue as possible future
work and are intentionally not implemented here.
2026-09-10 11:54:59 -05:00
fen 9621faa052 Merge fix-171-r2 into dev (QA for #185): take dev's new.js (already data:-URL) for conflict
CI / test (push) Successful in 45s
CI / docker (push) Successful in 51s
2026-09-10 11:52:18 -05:00
fen 1a8da8cd7a Merge pull request 'Fix #167: gutter realignment with wrapped lines' (#188) from fix-167 into dev
CI / test (push) Successful in 40s
CI / docker (push) Successful in 45s
CI / test (pull_request) Successful in 47s
CI / docker (pull_request) Skipped
# Conflicts:
#	internal/web/static/app.css
2026-09-10 11:44:10 -05:00
fen 368620eb15 Merge pull request 'fix #171: CSP-compliant image preview (data: URL instead of blob:)' (#186) from fix-171 into dev
CI / test (push) Successful in 41s
CI / docker (push) Successful in 50s
2026-09-10 16:42:15 +00:00
fen 5fa728f276 Merge pull request 'Fix #169: persistent full-width divider under stats dropdown title' (#182) from fix-169 into dev
CI / test (push) Successful in 43s
CI / docker (push) Successful in 50s
2026-09-10 16:41:11 +00:00
fen 80e761f53d Fix #169: persistent full-width divider under stats dropdown title
CI / test (pull_request) Successful in 40s
CI / docker (pull_request) Skipped
The divider between the stats dropdown head and body lived on
.stats-body's border-top, so it vanished when collapsed and only the
hover background hinted at the boundary (flicker). Move it to
.stats-head border-bottom so it is always present and spans the full
pill width.
2026-09-10 11:40:59 -05:00
fen 158c0fe4b6 Fix #167: block-level gutline spans so gutter numbers align with wrapped lines
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
QA on dev showed .codeline spans wrap correctly but gutter .gutline spans
rendered inline (all at the same top), so numbers 6+ fell off with wrap on.
Add .gutter .gutline { display: block; } so each gutter number occupies its
own visual row, matching the one-number-per-visual-row contract of
paste-lines.js. Verified rendered output in a local build at 1400x900 and
375x812 with a long unbroken token on line 5 and wrap ON.
2026-09-10 11:39:34 -05:00
fen 02fcb80b6d Merge pull request 'Revise notification pill (#168)' (#180) from fix-168 into dev
CI / test (push) Successful in 39s
CI / docker (push) Successful in 56s
2026-09-10 16:38:33 +00:00
fen f947415b23 fix #171: use FileReader data: URL for image preview (CSP blocks blob:)
CI / test (pull_request) Successful in 39s
CI / docker (pull_request) Skipped
2026-09-10 11:38:22 -05:00
fen f6c7bfa66b Revise notification pill per reopened #168: short text, smaller, side placement
CI / test (pull_request) Successful in 39s
CI / docker (pull_request) Skipped
- Pill now reads only 'Paste Created' (no combined 'Paste Created - Link Copied')
- Copy fallback toast now says 'Link Copied'
- Pill shrunk (smaller padding/font) and anchored tighter to the right side

Fixes #168
2026-09-10 11:38:13 -05:00
fen 0e5cfcd403 Merge pull request 'Revise notification pill (#168)' (#181) from fix-168 into dev
CI / test (push) Successful in 42s
CI / docker (push) Successful in 50s
2026-09-10 16:38:08 +00:00
fen d402113f19 Merge pull request 'Fix #167 rev: gutter numbers realign with wrapped lines (missing .gutline CSS)' (#183) from fix-167-rev into dev
CI / test (push) Successful in 41s
CI / docker (push) Successful in 54s
2026-09-10 16:37:57 +00:00
fen bb7251bd25 Fix #171 (r2): file image preview uses data: URL (FileReader) instead of blob: for CSP compliance
CI / test (pull_request) Successful in 41s
CI / docker (pull_request) Skipped
2026-09-10 11:37:39 -05:00
fen 9692531a40 Fix #167 rev: add missing .gutline CSS so gutter numbers align with wrapped lines
CI / test (pull_request) Successful in 41s
CI / docker (pull_request) Skipped
QA of PR #179 found paste-lines.js creates .gutline spans but no CSS rule
existed, so all gutter numbers rendered inline on one visual row. Stack
them one per row under .code .gutter; blank fill spans inherit it.
2026-09-10 11:35:39 -05:00
fen b801d7fb56 Revise notification pill per reopened #168: short text, smaller, side placement
CI / test (pull_request) Successful in 40s
CI / docker (pull_request) Skipped
- Pill now reads only 'Paste Created' (no combined 'Paste Created - Link Copied')
- Copy fallback toast now says 'Link Copied'
- Pill shrunk (smaller padding/font) and anchored tighter to the right side

Fixes #168
2026-09-10 11:33:45 -05:00
fen 362a59690f Merge pull request 'Clean up notification pill (#168)' (#178) from fix-168 into dev
CI / test (push) Successful in 40s
CI / docker (push) Successful in 50s
2026-09-10 16:25:56 +00:00
fen 0643ca433e Merge pull request 'Fix #167: gutter numbers realign with wrapped lines' (#179) from fix-167 into dev
CI / test (push) Successful in 39s
CI / docker (push) Successful in 49s
2026-09-10 16:25:15 +00:00
fen 6064404723 Merge remote fix-168: keep corner pill, cookie-based token flow
CI / test (pull_request) Successful in 40s
CI / docker (pull_request) Skipped
2026-09-10 11:25:15 -05:00
fen aa4d017631 Fix #168: compact corner notification pill for paste creation 2026-09-10 11:24:24 -05:00
fen dee062dfa5 Fix #167: gutter numbers track wrapped visual rows when line wrap enabled
CI / test (pull_request) Successful in 38s
CI / docker (pull_request) Skipped
2026-09-10 11:23:49 -05:00
fen 6ef0de6e19 Merge remote-tracking branch 'origin/dev' into qa178
# Conflicts:
#	internal/web/templates/paste.html
2026-09-10 11:23:47 -05:00
fen 28a79b20ea Merge pull request 'editor head: wrap toggle state and pill highlight (#166)' (#172) from fix-166 into dev
CI / test (push) Successful in 37s
CI / docker (push) Successful in 43s
2026-09-10 16:21:28 +00:00
fen 3fa2c93e22 clean up notification pill wording, size, and placement (#168)
CI / test (pull_request) Successful in 39s
CI / docker (pull_request) Skipped
2026-09-10 11:18:57 -05:00
fen bead90699d Merge pull request 'Fix #170: remove stray bar at bottom of /new page' (#177) from fix-170 into dev
CI / test (push) Successful in 33s
CI / docker (push) Successful in 37s
2026-09-10 16:18:42 +00:00
fen d7fb87f19b Fix #170: stray bar at bottom of /new page
CI / test (pull_request) Successful in 36s
CI / docker (pull_request) Skipped
#139 removed inline display:block from paste.html's created banner and
compensated with a global .created-banner { display:block } override.
That override also hit the hidden placeholder banner on /new, whose
base rule is display:none, making an empty padded bar render above the
action bar. Scope the override to .created-banner.show and mark only
the paste-view banner with the show class.
2026-09-10 11:16:30 -05:00
fen b9153c1d95 Merge pull request 'File upload preview in editor (#171)' (#175) from fix-171 into dev
CI / test (push) Successful in 32s
CI / docker (push) Successful in 40s
2026-09-10 16:13:00 +00:00
fen 51685f8667 file upload preview in editor on /new (#171)
- images render fitted into the editor area (object-fit contain), editor hidden
- text files load content into the editor; title placeholder convention (Python.py, Text.txt fallback)
- title auto-fills only when blank; never overwrites a typed title
- external JS + CSS classes only, CSP-safe (no inline styles/scripts)
2026-09-10 11:12:41 -05:00
fen 269acf1fa5 Merge pull request '#173: render friendly Paste ID not found UI instead of bare 404' (#174) from fix/173-paste-not-found into dev
CI / test (push) Successful in 33s
CI / docker (push) Successful in 44s
2026-09-10 16:09:14 +00:00
fen 122c1e14f4 web: friendly Paste ID not found page for missing pastes (#173)
CI / test (pull_request) Successful in 32s
CI / docker (pull_request) Skipped
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.
2026-09-10 11:06:52 -05:00
fen 9712926cf5 #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.
2026-09-10 11:06:44 -05:00
fen 5fc5ae8aad editor head: wrap toggle state and pill highlight (#166)
CI / test (pull_request) Successful in 32s
CI / docker (pull_request) Skipped
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.
2026-09-10 10:13:42 -05:00
fen 5dba356264 Merge pull request 'Settings spacing between sections (#161)' (#165) from fix-161 into dev
CI / test (push) Successful in 31s
CI / docker (push) Successful in 39s
2026-09-10 15:10:37 +00:00
fen 4683b8b542 new paste tab: rework number box submenus (#157)
CI / test (push) Successful in 31s
CI / docker (push) Successful in 37s
Fixes #157
2026-09-10 14:45:50 +00:00
fen 16cad8796f new paste tab: rework number box submenus (#157)
CI / test (pull_request) Successful in 32s
CI / docker (pull_request) Skipped
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.
2026-09-10 09:45:31 -05:00
fen 317ed2137a Settings: more padding between sections (#161)
CI / test (pull_request) Successful in 37s
CI / docker (pull_request) Skipped
Add margin under the dark mode toggle and between section headings
(theme/editor) on /settings via CSS classes, no inline styles (CSP).
2026-09-10 09:41:45 -05:00
fen 93fea6ad1c Merge pull request 'Fix #163: /new Create button broken (stale iscan/createCan path)' (#164) from fix-163 into dev
CI / test (push) Successful in 32s
CI / docker (push) Successful in 40s
2026-09-10 14:40:52 +00:00
fen 0787871f2b Fix #163: remove dead iscan/createCan path breaking /new Create
CI / test (pull_request) Successful in 31s
CI / docker (pull_request) Skipped
2026-09-10 09:33:49 -05:00
fen 85dcaea522 Merge pull request 'Fix #142: long unbroken paste title caused double horizontal scrollbar at mobile width' (#162) from fix-142 into dev
CI / test (push) Successful in 32s
CI / docker (push) Successful in 37s
2026-09-10 14:33:28 +00:00
fen c4d55cb196 Fix #142: break long unbroken paste titles instead of forcing horizontal scroll
CI / test (pull_request) Successful in 34s
CI / docker (pull_request) Skipped
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.
2026-09-10 09:33:15 -05:00
fen 5a227e2feb Merge pull request 'Fix #139: drop unsafe-inline from script-src and style-src' (#159) from fix-139 into dev
CI / test (push) Successful in 31s
CI / docker (push) Successful in 36s
2026-09-10 09:11:00 -05:00
fen 7a74ff3e74 Fix #139: drop unsafe-inline from script-src and style-src (#139)
CI / test (pull_request) Successful in 30s
CI / docker (pull_request) Skipped
- Move all inline <script> blocks (layout head/theme, topbar dark toggle,
  foot, paste, new, history, mine, settings, admin, unlock) to external
  files under internal/web/static/. Page data reaches scripts via data-*
  attributes (data-paste-id, data-default-dark) instead of template vars.
- Replace inline onclick handlers (copy, delete, stats toggle) with
  addEventListener wiring.
- Convert inline style="" attributes to CSS utility classes; swatch
  colors are now set via CSSOM/DOM APIs instead of innerHTML strings.
- script-src/style-src are now plain 'self'; img-src data: stays for the
  SVG data-URI backgrounds. Verified with headless chromium: zero CSP
  violations on all pages in dark and light presets, theme swatches,
  admin lock, tables and paste view render correctly.
2026-09-10 09:08:17 -05:00
fen fc07d8b9d7 Merge pull request 'Wrap: break long words, no horizontal scroll while wrap enabled' (#155) from fix-152 into dev
CI / test (push) Successful in 30s
CI / docker (push) Successful in 36s
2026-09-10 13:51:27 +00:00
fen b30d41b87d Wrap ON breaks long tokens mid-word; no horizontal scroll while wrap enabled (#152)
CI / test (pull_request) Successful in 30s
CI / docker (pull_request) Skipped
- 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
2026-09-10 08:51:13 -05:00
fen ca77250cce Merge pull request 'Fix #138: add Secure attribute to viewer cookie (vwr)' (#154) from fix-138 into dev
CI / test (push) Successful in 30s
CI / docker (push) Successful in 35s
2026-09-10 13:43:21 +00:00
fen 521b6f8011 #138: add Secure attribute to viewer cookie (vwr)
CI / test (pull_request) Successful in 29s
CI / docker (pull_request) Skipped
2026-09-10 08:42:12 -05:00
fen fe75dfc746 Merge pull request 'Remove ?token= deletion-token path (#143)' (#153) from fix-143 into dev
CI / test (push) Successful in 29s
CI / docker (push) Successful in 36s
2026-09-10 13:38:15 +00:00
fen 00aaafeb3c #143: set tok_<id> cookie in create handlers so the created banner can show the token (QA)
CI / test (pull_request) Successful in 29s
CI / docker (pull_request) Skipped
2026-09-10 08:37:07 -05:00
fen d7b51f02b6 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_<id>
  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
2026-09-10 08:37:07 -05:00
fen b0a58d6ca5 Merge pull request 'Settings: dark toggle above theme list; midnight card light/dark rows swapped' (#134) from fix-settings-order into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 41s
2026-09-10 13:23:29 +00:00
fen 747eba7064 Settings: dark toggle above theme list; midnight card light row on top (#132)
CI / test (pull_request) Successful in 29s
CI / docker (pull_request) Skipped
2026-09-10 08:21:49 -05:00
fen 33ccfd373a Merge pull request 'Fix invisible select chevron in all presets (#136)' (#151) from fix-136-select-chevron into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 39s
2026-09-10 04:31:27 +00:00
fen 72dbfb1b3c Fix invisible select chevron in all presets (#136)
CI / test (pull_request) Successful in 28s
CI / docker (pull_request) Skipped
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.
2026-09-09 23:25:15 -05:00
fen d44e4d1587 Merge pull request 'Secrets via headers only: drop ?key= and ?password= query fallbacks (#137, #141)' (#150) from fix-137-141-drop-query-secrets into dev
CI / test (push) Successful in 27s
CI / docker (push) Successful in 38s
2026-09-10 04:23:27 +00:00
fen d013f3965f Require secrets via headers only: drop ?key= and ?password= query fallbacks (#137, #141)
CI / test (pull_request) Successful in 28s
CI / docker (pull_request) Skipped
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.
2026-09-09 23:23:10 -05:00
fen 22c66243d5 Merge fix-38-file-attachments into dev (PR #129)
CI / test (push) Successful in 28s
CI / docker (push) Successful in 36s
2026-09-09 23:20:48 -05:00
fen 98222e762f Merge pull request 'History: pagination survives search/sort, pager pinned to page bottom' (#140) from fix-133-paging into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 34s
2026-09-10 04:14:20 +00:00
fen a95ee57000 Merge pull request 'Split new-paste editor header into title pill and settings pill (#147)' (#149) from fix-147-header-split into dev
CI / test (push) Successful in 31s
CI / docker (push) Successful in 33s
2026-09-10 04:12:05 +00:00
fen b096c2073c Merge pull request 'Fix result box wrap: word boundaries, keep long-token breaking (#135)' (#145) from fix-135-result-wrap into dev
CI / test (push) Successful in 27s
CI / docker (push) Successful in 41s
2026-09-10 04:09:13 +00:00
fen 4d50e644b9 Merge pull request 'Fix #146: untitled paste ID fallback renders as plain text like titled pastes' (#148) from fix-146-id-fallback-plain into dev
CI / test (push) Successful in 27s
CI / docker (push) Successful in 39s
2026-09-10 04:07:04 +00:00
fen 8abf821ce9 Merge pull request 'Admin lock page: replace explanatory paragraph with centered key input' (#119) from fix-admin-lock-center into dev
CI / test (push) Successful in 27s
CI / docker (push) Successful in 32s
2026-09-10 04:01:45 +00:00
fen da9039b606 Split new-paste editor header into title pill and settings pill (#147)
CI / test (pull_request) Successful in 30s
CI / docker (pull_request) Skipped
2026-09-09 22:58:41 -05:00
fen 7dc70d62f6 Fix #146: render untitled paste ID fallback as plain text like titled pastes
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
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.
2026-09-09 22:56:53 -05:00
fen 0073335875 #38 QA fix: render GetAttachmentForPaste in paste view; dedupe dark-toggle CSS
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
- 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.
2026-09-09 22:53:39 -05:00
fen fa2e9b1bb5 History: keep pagination over filtered/sorted set, pin pager to page bottom (#133)
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-09 22:48:25 -05:00
fen 9f4ed91b8f Fix result box wrap: word boundaries instead of break-all (#135)
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-09 22:43:30 -05:00
fen 24482abccc Merge pull request 'Line wrap toggle for editor and paste viewer (#130)' (#131) from fix-130-line-wrap into dev
CI / test (push) Successful in 26s
CI / docker (push) Successful in 44s
2026-09-10 03:39:45 +00:00
fen 7cc6a9c706 Add line wrap toggle for editor and paste viewer (#130)
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
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.
2026-09-09 22:35:49 -05:00
fen 19cb8d1d45 Merge pull request 'Theme pairs with dark mode toggle, gear active state, PALETTE_DEFAULT_DARK' (#128) from fix-theme-pairs-darkmode into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 32s
2026-09-10 03:25:35 +00:00
fen 4948ef9f1c #38 iteration 1: file attachments, 1 file per paste
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
- internal/store/blob.go: BlobStore interface + fs implementation with
  traversal-safe keys (<paste-id>/<sha256>), 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)
2026-09-09 22:21:15 -05:00
fen dcf2d66a82 Theme pairs with dark mode toggle, gear active state, PALETTE_DEFAULT_DARK (#127)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
- 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
2026-09-09 22:17:50 -05:00
fen 821f49993f 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
2026-09-09 22:17:04 -05:00
fen f1b5dd0364 Merge pull request 'Remove custom URL reservation note and Can contents menu' (#126) from fix-124-remove-note-can-menu into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 40s
2026-09-09 22:05:05 -05:00
fen 8eabeb4839 Merge dev into fix-124: resolve new.html conflict, keep #124 removals and #125 placeholder
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-09 21:53:00 -05:00
fen 7ed50087a6 Merge pull request 'UI polish: custom URL placeholder, styled buttons/selects, admin link spacing' (#125) from fix-ui-polish into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 35s
2026-09-10 02:49:36 +00:00
fen 8104cdba4c Remove custom URL reservation note and Can contents menu from new paste page
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
Fixes #124
2026-09-09 21:47:50 -05:00
fen be3ff81e5c base 2026-09-09 21:45:42 -05:00
fen 1e317d11da UI polish: placeholder slash, styled controls, admin link spacing (#123)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 21:45:22 -05:00
fen 2ce50a6393 Merge pull request 'Fix #120: add matching light/dark variant for every theme' (#121) from fix-theme-variants into dev
CI / test (push) Successful in 27s
CI / docker (push) Successful in 35s
2026-09-10 02:36:47 +00:00
fen 70c284b15d Fix #120: add matching light/dark variant for every theme
CI / test (pull_request) Successful in 31s
CI / docker (pull_request) Skipped
2026-09-09 21:33:56 -05:00
fen 0ceeabc810 Merge dev: burn fix, version label, theme persistence, admin/settings UI, theme grid, CSP fix, docs
CI / test (push) Successful in 21s
CI / docker (push) Successful in 47s
2026-09-09 21:32:30 -05:00
fen 8dde83ff68 Admin lock: replace explanation paragraph with centered key input
CI / test (pull_request) Successful in 28s
CI / docker (pull_request) Skipped
2026-09-09 21:28:11 -05:00
fen aca99bc6d4 Merge pull request 'Fix #116: allow inline styles in CSP (style-src)' (#117) from fix-116-csp-inline-styles into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 36s
2026-09-10 02:20:53 +00:00
fen 073d39ccba Fix #116: allow inline styles in CSP (style-src 'self' 'unsafe-inline')
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 21:18:53 -05:00
fen 9ec7d5152e Merge pull request 'Fix #114: theme grid empty (t.colors undefined)' (#115) from fix-114-theme-grid-empty into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 42s
2026-09-10 02:01:38 +00:00
fen a677e627a8 Fix #114: use presetColors(t.id) for theme card swatches
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
t.colors was removed by #113; rendering threw on first iteration and
the theme grid rendered empty. Use the colors returned by
presetColors(t.id) instead. Verified in headless chromium: 5
theme-card elements render on /settings.
2026-09-09 20:55:13 -05:00
fen 7a2fa00f4a Merge pull request 'Fix admin lock-on-load, settings hint, theme swatches (#112)' (#113) from fix-112-admin-settings-ui into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 34s
2026-09-10 00:54:43 +00:00
fen 6b95994c53 Admin lock on every load, drop settings hint, derive theme swatches from CSS vars (#112)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 19:52:32 -05:00
fen 7fde147500 Merge pull request 'Fix theme persistence on reload' (#109) from fix-100-theme-persist into dev
CI / test (push) Successful in 23s
CI / docker (push) Successful in 38s
2026-09-10 00:22:04 +00:00
fen cf8674b898 Merge pull request 'Fix wiki docs accuracy (#110)' (#111) from fix-110-docs-accuracy into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 32s
2026-09-10 00:20:17 +00:00
fen f6e81de2a3 Fix README: build path ./cmd/palette, Go 1.27+, design-doc wiki link (#110)
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-09 19:15:44 -05:00
fen e17ea4f8c2 Move docs to Gitea wiki; point README links at wiki
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 18:52:41 -05:00
fen 5afe81f34c README: AI-built disclaimer before feature list
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 18:46:15 -05:00
poslop 097ffb7971 Update README.md
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 23:44:37 +00:00
poslop 815635a973 Update README.md
CI / test (push) Successful in 28s
CI / docker (push) Skipped
2026-09-09 23:42:06 +00:00
fen df73d56e32 Merge remote-tracking branch 'origin/fix-93-version-label' into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 49s
2026-09-09 18:41:26 -05:00
fen 9d89995c91 Merge pull request 'store: burn_after_reads>0 implies burn-after-read (#82)' (#107) from fix-82-burn-implies-budget into dev
CI / test (push) Successful in 24s
CI / docker (push) Successful in 37s
2026-09-09 23:40:22 +00:00
fen 805cc3ff2b web: restore persisted theme on page load (#100)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 18:40:09 -05:00
poslop f37cc932c4 store: burn_after_reads>0 implies burn-after-read mode (#82)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 18:28:00 -05:00
agent 65ed0c7043 Show actual version in topbar instead of hardcoded beta (Fixes #93)
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-09 18:27:57 -05:00
poslop 8ecf5338d5 CI: build dev image on dev branch pushes (palette-dev deploy)
CI / test (push) Successful in 25s
CI / docker (push) Successful in 42s
2026-09-09 18:22:31 -05:00
poslop 69da340224 Settings page: theme switcher with live preview (#100)
CI / test (push) Successful in 23s
CI / docker (push) Skipped
2026-09-09 18:21:45 -05:00
poslop b1af398bea Update README.md
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-09 22:56:50 +00:00
poslop 15f199b2fe README: trim to brief front page + retake themed screenshots (#106)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
- Cut Admin walkthrough and API curl block to one-liners pointing at docs
- Keep Configuration table, features, Get Started, doc links
- Retake all screenshots against current UI, one theme each:
  /new midnight, /history pastel-lavender, paste view pastel-peach,
  /mine pastel-cloud; remove superseded stale previews
2026-09-09 17:30:54 -05:00
poslop 9df6224a27 Result box: color-code by status + friendly error messages from error codes (#105)
CI / test (push) Successful in 23s
CI / docker (push) Skipped
- Backend create/can validation paths emit machine-readable error codes
  (slug_taken, slug_invalid, content_empty, content_too_large,
  expiry_invalid, rate_limited, ...) alongside the human message
- new.html JS maps codes to plain-language guidance with generic fallback
- Result card colored via --ok/--err/--warn left border (result-ok/err/warn)
- docs/API.md error section documents the code field
- Tests assert the code on every validation path
2026-09-09 17:28:25 -05:00
poslop c35531e03e Settings page: small muted Admin link (#102)
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 17:17:00 -05:00
poslop be2b4fb47f Sort arrows: left of label, further spaced, vertically centered, no label shift (#101)
Arrow is absolutely positioned at left:8px in the th, label padding-left
24px; translateY(-50%) keeps it centered regardless of row height, and
being out of flow the label never moves between sorted/unsorted states.
2026-09-09 17:17:00 -05:00
poslop 064f05536a Admin page: validate key before persisting to sessionStorage (#99)
Panel was already display:none-gated until auth; tightened so a wrong
submitted key is never stored in sessionStorage — the typed key is
validated via the settings API first, and only persisted on 200.
2026-09-09 17:17:00 -05:00
poslop 4175ffef3e CSS: inline sort arrow with uniform 8px gap (#103), full-pill slug roundness (#104)
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 17:10:24 -05:00
poslop ea9edc211a UI: consistent sort arrow gap, uniform pill roundness (slug -> radius-sm)
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 16:50:06 -05:00
poslop 6d467312af Merge minor fixes: #92-#98, #100 theme switcher + cans work
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 16:40:53 -05:00
poslop b21be29daa minor fixes: version label (#93), view dedupe tests (#95) 2026-09-09 16:40:53 -05:00
poslop cd70dd0b0e Merge origin/main (license, compose, #65 leak guard) into cans work
CI / test (push) Failing after 6s
CI / docker (push) Skipped
2026-09-09 16:39:14 -05:00
poslop 5b9be808ed Merge origin/main: keep can listing rows with #65 password-metadata leak guard 2026-09-09 16:36:55 -05:00
poslop 19804d47a3 Cans UI + parity: unlock-cookie flow, /can page, listings badge, custom slug, delete, sweeper; #32 perf notes (#4, #32) 2026-09-09 16:34:51 -05:00
88 changed files with 4940 additions and 1373 deletions
+5 -4
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: [main, dev]
tags: ['v*']
pull_request:
@@ -23,8 +23,8 @@ jobs:
run: go test -v ./...
docker:
# build & push image only on tags (releases)
if: startsWith(github.ref, 'refs/tags/')
# build & push image only on tags (releases) and dev branch (dev deploy)
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/dev'
needs: test
runs-on: [debian-latest]
env:
@@ -55,8 +55,9 @@ jobs:
with:
context: .
push: true
build-args: VERSION=${{ gitea.ref_name }}
tags: |
git.archfox.org/poslop/palette:${{ gitea.ref_name }}
git.archfox.org/poslop/palette:latest
${{ startsWith(gitea.ref, 'refs/tags/') && 'git.archfox.org/poslop/palette:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -4,3 +4,4 @@ palette.db-shm
palette.db-wal
admin-key
settings.json
:memory:.files/
+2 -1
View File
@@ -9,7 +9,8 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette ./cmd/palette
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X palette/internal/web.Version=${VERSION}" -o /palette ./cmd/palette
# ---- runtime stage ----
FROM alpine:3.20
+50 -63
View File
@@ -1,33 +1,39 @@
# Palette
Palette is a fast, self-hosted pastebin. One Go binary, a SQLite database, and
a web UI for sharing code and text with links that expire on your terms.
a web UI for sharing text and small files.
> [!NOTE]
> <table><tr><td>
> <sub>✨ This project was built by AI — <strong>GLM 5.3 Flash</strong> on the <strong>Hermes Agent</strong> harness, with a human in the loop.</sub>
> </td></tr></table>
## Features
- Paste cans — bundle notes, text, and files into one shareable page
- Password lock — protect individual pastes with a password
- Custom expiry — from 1 minute up to 1 year, or never
- Burn after N reads — a paste that vanishes after a chosen number of reads
- Custom URLs — reserve `/my-snippet` instead of a random slug
- Text pastes and cans (multiple items in one share)
- File attachments (one file per paste, up to 25 MB)
- Password protected pastes
- Expire after a specified time
- Burn after a number of views
- Custom URLs
- Syntax highlighting with language auto-detection (go-enry)
- Rate limiting on create and unlock
- Saved page — see and manage everything created from your browser
- API-first — every UI action is also a plain HTTP call
- Single binary — templates and assets are embedded; no external deps
- Public paste listing with search, sort and pagination
- Cookie based saved pastes and settings
- Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant
- Dark mode toggle in the topbar and settings, with a configurable default
- Polished code viewer: line-number gutter sized to the widest number and pinned during horizontal scroll, optional line wrap, jump-to-top/bottom buttons, and theme-aware scrollbars
## Screenshots
| | |
|---|---|
| ![Editor in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-editor-new.png) | ![Paste view in pastel-peach (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-pastel-peach-light.png) |
| ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) |
## Get Started
### Build from source
Requires Go 1.21+.
```bash
go build -o palette .
./palette
# open http://localhost:8080
```
### Docker
```bash
@@ -36,12 +42,22 @@ docker run -p 8080:8080 -v palette-data:/data git.archfox.org/poslop/palette
The SQLite database lives in the `/data` volume inside the container.
## Screenshots
## Docker Compose
See [docker-compose.yml](docker-compose.yml) for a ready-to-use example with
every environment variable documented, including which are required (only the
/data volume) and which are optional.
### Build from source
Requires Go 1.27+.
```bash
go build -o palette ./cmd/palette
./palette
# open http://localhost:8080
```
| | |
|---|---|
| ![Editor](docs/palette-previews/pastel-lavender-new.png) | ![Paste view](docs/palette-previews/pastel-lavender-paste.png) |
| ![History](docs/palette-previews/midnight-history.png) | |
## Configuration
@@ -50,56 +66,27 @@ The SQLite database lives in the `/data` volume inside the container.
| `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
### Admin
`GET /admin` serves the admin page. Enter the admin key there — it is stored in
`sessionStorage` (never a cookie) and sent as the `X-Admin-Key` header on
`GET`/`POST /admin/api/settings`.
The admin API reads/sets: rate-limit burst, rate-limit refill per minute, max
content bytes, default expiry, custom URL reservation days, and the burn
viewer window (minutes). All admin access attempts are logged.
```bash
./palette --reset-admin-key # regenerate the admin key and print it
```
An `/admin` page exists for runtime settings, protected by a key set at
install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See
[API](https://git.archfox.org/poslop/palette/wiki/API) and the [design docs](https://git.archfox.org/poslop/palette/wiki/Home) in the wiki for details.
## API
Create a paste with one call:
Full REST API: [wiki/API](https://git.archfox.org/poslop/palette/wiki/API). One example:
```bash
curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \
-d '{"content": "print(hello)", "language": "python", "expires_in": "168h"}'
curl -X POST http://localhost:8080/api/pastes -d '{"content":"hello"}'
```
Full API docs: [docs/API.md](docs/API.md). Design docs: [docs/design/](docs/design/) (currently: [client-side E2E encryption](docs/design/e2e-encryption.md), issue #39).
Design docs: [wiki/design](https://git.archfox.org/poslop/palette/wiki/design-attachments-storage) (e2e encryption, attachments & storage, cookie preferences).
## Performance Notes
The history and Saved pages use client-side filtering: when you type in the
search box, the UI fetches the most recent 100 pastes (`limit=100`, the API
maximum) once per query and filters/sorts them in the browser. Pastes beyond
the newest 100 are not searched; a match count against the full total is still
shown. This keeps search instant without a server-side query. If large
instances need full search later, it will be a server-side endpoint (see
issue #32).
## CI
Gitea Actions workflow at `.gitea/workflows/ci.yml`:
- On push to main: `go vet` + `go test`
- On tags: build and push Docker image to `git.archfox.org/poslop/palette`
## Docker Compose
See [docker-compose.yml](docker-compose.yml) for a ready-to-use example with
every environment variable documented, including which are required (only the
/data volume) and which are optional.
## License
+1
View File
@@ -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()))
+7 -1
View File
@@ -32,10 +32,16 @@ services:
# Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can).
# Max size in bytes of a single can item (file/text inside a can) or a
# paste file attachment.
# Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400"
# Default dark mode for new visitors. Unset = dark on; set to "false",
# "0" or "off" to default to light mode. Visitors who toggle dark mode
# keep their choice in their browser.
# PALETTE_DEFAULT_DARK: "false"
# HMAC secret for password-unlock cookies. Default: random per start,
# which logs out every unlocked browser session on restart. Set a fixed
# secret (any random string) to keep unlock sessions across restarts,
-113
View File
@@ -1,113 +0,0 @@
# Palette API
All endpoints are JSON unless noted. The web UI is served from the same port.
## Create paste
```bash
curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \
-d '{
"content": "print(hello)",
"title": "my snippet",
"language": "python",
"expires_in": "168h",
"password": "optional",
"custom_slug": "optional",
"burn_after_read": false,
"burn_after_reads": 1,
"visibility": "public"
}'
```
- `expires_in` is a Go duration string (`90m`, `6h`, `336h`). Omit for no expiry.
- `visibility` is `public` or `unlisted`.
- Alternatively (or additionally), `public` may be sent as a boolean (#83):
`false` maps to `unlisted` and `true` maps to `public`. When both fields are
present, the boolean `public` takes precedence over the string `visibility`.
Omitting both defaults to `public`.
- `burn_after_reads` sets how many reads the paste survives (default 1 when
`burn_after_read` is true). A read is counted per unique viewer session;
the same viewer returning within 15 minutes does not count again.
- Response includes `id`, `url`, `raw_url`, `api_url`, `expires_at`,
`created_at`, and a one-time `deletion_token`.
Errors: `400` invalid body/content too large/duplicate slug, `401` password
required, `404` paste expired/burned/gone, `413` content exceeds max bytes,
`429` rate limited.
## Get paste
```bash
curl http://localhost:8080/api/pastes/{id}
# password-protected pastes:
curl "http://localhost:8080/api/pastes/{id}?password=secret"
# or via header: X-Paste-Password: secret
```
The response includes `reads_remaining` (`null` when no read budget is set).
## Raw content
```bash
curl http://localhost:8080/raw/{id}
```
Raw reads count against a burn-after-read budget, same as page views.
## Delete
```bash
# soft delete (requires the deletion token from the create response)
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/{id}
# ...or via query param; the creator browser (viewer cookie) may also delete without a token
curl -X DELETE "http://localhost:8080/api/pastes/{id}?token=TOKEN"
# hard delete immediately (requires the one-time deletion token)
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
```
## Lists
```bash
curl "http://localhost:8080/api/public?limit=25&offset=0" # public history
curl http://localhost:8080/api/mine # this browser's pastes (viewer cookie)
```
## Language detection
```bash
curl -X POST http://localhost:8080/api/guess-language \
-H "Content-Type: application/json" \
-d '{"content": "package main"}'
```
## Cans (bundles of items)
```bash
curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \
-F "expires_in=48h" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png" \
-F "files=@log.txt"
curl http://localhost:8080/api/cans/{id}
curl http://localhost:8080/api/cans/{id}/items/{item_id}
```
## Web pages
- `/new` — create a paste
- `/history` — public paste history
- `/mine` — pastes created from this browser
- `/{id}` — view a paste
- `/unlock/{id}` — password gate for protected pastes
- `/raw/{id}` — raw content with original content type
## Expiry and deletion
- Expired pastes are soft-deleted by a background sweeper (runs every minute).
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- Deletion tokens allow immediate hard delete.
- Burn-after-read pastes are soft-deleted once the read budget is exhausted.
-78
View File
@@ -1,78 +0,0 @@
# Attachments & Storage Backend Design (#38, #31)
Status: research/design, no implementation. Consumers: paste cans (#4).
## Part A — Attachments: S3/MinIO vs filesystem-on-volume (#38)
### Options
**Option 1: Filesystem on the k3s PVC (current 5Gi volume).**
Store blobs under `<data-dir>/attachments/<paste-id>/<n>-<sha256-8>`, metadata in SQLite (paste_id, filename, size, sha256, mime, created_at).
- Pros: zero new infra, zero new credentials, trivial backup (the volume backup job already covers the DB), atomic rename on write, works in dev and prod identically.
- Cons: volume is size-capped (5Gi today; resizable but bounded); serving large files passes through the app process (no ranged-GET offload); multi-replica later would need RWX volume.
**Option 2: MinIO via S3 API.**
MinIO is already proven in this homelab (Outline). Store at key `<paste-id>/<n>`; same SQLite metadata row.
- Pros: effectively unbounded capacity, presigned URLs (direct browser download, offloads serving from palette pods), ranged requests free, lifecycle rules could auto-expire orphaned objects.
- Cons: another credential/secret to manage, another failure mode, MultipartForm still terminates at the palette pod (MinIO only helps *serving*, not *uploading*, unless we do presigned uploads — which breaks the cans multipart flow and the auth/unlock checks), backup now spans two systems.
### Key considerations
- **Upload path is the same either way.** Palette receives `multipart/form-data` (cans need text items + file drops in one request), must enforce auth/password/burn rules server-side. A filesystem backend adds no upload complexity; S3 adds an extra hop (buffer → PUT to MinIO). Streaming straight from `multipart.Reader` to the sink works for both (`io.Copy` to a temp file, or to an S3 PUT with `Content-Length` known or multipart buffering).
- **Size limits.** Everything is admin-tunable via the settings API (#40 pattern) — add `max_attachment_bytes` (default 10 MiB, hard server-side cap checked *before* reading the body via `Content-Length`, plus a counted reader during copy so chunked uploads can't lie). SQLite itself is not a constraint either way; the PVC is the real cap for Option 1.
- **MIME handling.** Security-critical (pentest #34 already fixed a content-type XSS on `/raw`). Rules:
- Never trust the client-declared Content-Type. Sniff the first 512 bytes (`http.DetectContentType`), intersect with an allowlist.
- Serve from a dedicated route (`/{id}/a/{n}`) with `Content-Type` from the stored *sniffed* type, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`, `Content-Disposition: attachment` unless the type is on a safe-inline allowlist (text/plain, images, PDF at user opt-in).
- Never render user HTML/SVG inline (`image/svg+xml` is XSS-capable — serve as `attachment` always, or store sanitized).
- **Streaming & serving.**
- Filesystem: `http.ServeContent` on the opened file gives ranged GETs, ETag, Last-Modified for free.
- MinIO: proxy via `GetObject` + `io.Copy` (simple, keeps auth checks in palette) or presigned GET (faster, but URL embeds credentials-temporarily and bypasses palette's per-request auth — wrong for pastes with passwords/burn semantics). Given cans inherit password/burn parity (#4), **proxying is required, which erodes MinIO's main serving advantage**.
- **Lifecycle parity.** Attachments must honor soft-delete grace and sweep: sweeper hard-delete also removes blobs (files: `os.Remove`; S3: `DeleteObject`), best-effort with logging; orphan sweep job compares DB rows to store contents.
### Recommendation
**Filesystem-on-volume first.** At current scale (single replica, PVC-based deploy, one user + homelab traffic) it is simpler end-to-end and keeps serving/auth/lifecycle in one place. The internal API should be a narrow blob interface (`Put(ctx, key, r io.Reader, size int64) / Open(key) / Delete(key)`) — about 60 lines per backend — so **MinIO becomes a drop-in later** if attachments outgrow the volume. That's the honest middle path: filesystem default, S3-ready seam, no MinIO dependency until it pays for itself.
## Part B — SQLite vs Postgres vs Redis (#31)
### Assessment of SQLite at pastebin scale
- **Driver**: modernc.org/sqlite (pure Go, no cgo) — slightly slower than mattn/go-sqlite3 but fine; single-writer semantics are the real constraint, not driver speed.
- **Access pattern**: paste-heavy, write-rare/read-often; primary keys and small set of indexes (visibility+created, expires, deleted); no joins beyond can items. This is SQLite's best case.
- **WAL mode** is already on (`journal_mode(WAL), busy_timeout(5000)`) — concurrent readers don't block the single writer.
- **Numbers**: SQLite comfortably handles millions of rows and hundreds of reads/sec; WAL write throughput is thousands of small inserts/sec. A pastebin doing even 100k pastes (avg 10 KB = ~1 GB DB) is trivial. Reads: prepared `WHERE id=?` lookups at this size are sub-millisecond.
- **Weak points to watch** (document, none urgent):
1. Single writer — heavy concurrent create traffic serializes. Mitigation: already rate-limited (#2); fine until that's the bottleneck (unlikely).
2. `LENGTH(content)` on every list row — fine now; if it shows up in profiling, store `size` as a column (schema already has a `Size` field; list queries could use it).
3. Sweeper runs a table-wide `UPDATE`+`DELETE` on tick — indexed, fine.
4. No network access to the DB file — locks palette to single-replica. Acceptable: current deploy is one replica.
- **Redis is the wrong tool** here: it's a cache/queue, not a system of record. Pastes are durable data with expiry semantics already implemented in SQLite. Redis would only add an optional read-cache layer for hot pastes — pure complexity for zero measured need.
### Should we build a backend abstraction (SQLite default, optional Postgres)?
Arguments for: multi-replica scaling later; "docker image env choice" sounds nice; Postgres gives real concurrency and network access.
Arguments against: a `Store` interface covering the current query surface is a real refactor (sqlite-flavored SQL: `INSERT OR IGNORE`-style upserts, partial indexes, `?` placeholders are compatible but behaviors differ — e.g. `sqlite` driver pragmas, transaction isolation, `AUTOINCREMENT` semantics); two backends means two test matrices and two migration paths forever; and there is **no current need** — single replica, single writer, modest data.
**Recommendation: stay SQLite-only. Do not build the abstraction now.**
Specifically:
1. Keep all persistence behind `internal/store` (already done in #35 — the package boundary *is* the abstraction, at zero cost).
2. Avoid SQLite-specific SQL going forward where free (standard placeholders, no `RETURNING` quirks) — cheap discipline that keeps a future port honest.
3. Define the trigger conditions for revisiting, and write them down:
- multiple replicas needed (scale-out), or
- sustained WAL write contention (busy timeouts observed in logs), or
- DB file > ~5-10 GB, or
- a concrete user request for a Postgres-backed image.
4. When a trigger fires, port `internal/store` to Postgres behind an interface extracted *then* — the refactor is mechanical against a real need, instead of speculative complexity now.
5. For the docker image: `PALETTE_DB_PATH` env already implies the deployment choice; no extra backend knob needed.
## Summary
| Decision | Choice |
|---|---|
| Attachment backend | Filesystem on PVC, behind a ~3-method blob interface; MinIO as a later drop-in, not a dependency |
| Size limits | `max_attachment_bytes` admin setting, default 10 MiB, enforced pre-read + during stream |
| MIME | Server-side sniff (512 bytes) + allowlist; `nosniff`, CSP `sandbox`, `Content-Disposition: attachment` except safe-inline types; SVG never inline |
| Database | SQLite (WAL, modernc) only; no Postgres/Redis, no backend abstraction until a written trigger fires |
-160
View File
@@ -1,160 +0,0 @@
# Design: Cookie-Based Preferences and Access Keys (#30)
Status: design note — no implementation yet.
Related: #37 (vwr viewer cookie), #34 (HMAC unlock cookie), #26 (creator auto-unlock), #36 (settings gear).
## Current cookie surface
| Cookie | Purpose | Lifetime | Flags today |
|---|---|---|---|
| `vwr` | Anonymous viewer id; scopes `/mine` history and burn-after-N per-viewer dedupe; client-sent `vwr` also authorizes delete | 1 year | `HttpOnly`, `SameSite=Lax`, `Path=/` |
| `pw_<id>` | Per-paste password unlock token = HMAC(paste id, PALETTE_UNLOCK_SECRET) | 1 hour | `HttpOnly`, `SameSite=Lax`, `Path=/` |
| `tok_<id>` | One-time deletion-token handoff after create | 60 s | `HttpOnly`, `SameSite=Lax`, `Path=/` |
The access-key feature is an extension of the `pw_<id>` pattern, not a new mechanism.
## Part 1: Preference storage
### What settings
Only settings the *creator* sets when writing a paste, so the "new paste" form
can pre-fill them:
- Default language (`lang`)
- Default expiry (`expires_in` / custom expiry)
- Burn-after-N-reads default
- Password-protect-by-default toggle (checkbox pre-checked; the password itself is never stored)
- Default visibility of the "raw" link, if such a toggle exists
- Collapsed/expanded state of the settings gear panel itself
Never stored in cookies: passwords, access keys for pastes the user hasn't
unlocked, deletion tokens (beyond the existing 60 s `tok_` handoff), anything
typed into the paste body or title fields (existing rule: auto-detect must not
overwrite user-typed content).
### One cookie, not many
A single `prefs` cookie holding a compact JSON object:
```
prefs={"lang":"go","exp":"1h","burn":0,"pw":1}
```
- One cookie avoids the browser per-domain cookie count (typically 50+ per
domain; Chrome 180) eating the budget that per-paste access-key cookies need.
- Per-paste cookies (`pw_<id>`) are inherently name-per-paste and cannot be
consolidated — that's the constraint that makes a single `prefs` cookie
mandatory rather than stylistic.
### Size limits
- RFC 6265: user agents SHOULD support at least 4096 bytes per cookie. Keep
`prefs` under 256 bytes of JSON — it holds a handful of short enum values.
- Server behavior: if the cookie is present but oversized/invalid JSON, ignore
it silently and serve defaults. Never reject a request over a bad preference
cookie.
- Validate on the server (allowlist of known values); a cookie is untrusted
input like any header.
### Flags
`HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000; Secure` (Secure once the
prod instance serves HTTPS — it will, behind the letsencrypt IngressRoute;
dev on plain HTTP needs the flag conditional on config).
Preferences are not sensitive, but `HttpOnly` costs nothing and keeps script
from mutating them; `SameSite=Lax` matches the existing cookies.
## Part 2: Access-key cookies
### Goal
"Remember unlocked pastes on this browser" — after entering a password (or
after creating a private paste), subsequent visits skip the unlock form. This
extends `pw_<id>` from a 1-hour session convenience to a durable capability.
### Design: extend `pw_<id>`, don't invent a new scheme
The token is already HMAC(paste id, PALETTE_UNLOCK_SECRET) — unforgeable and
per-paste (fix for the #34 bypass). Changes:
1. **Opt-in checkbox on the unlock form** ("remember on this browser") and a
matching checkbox/note at creation time. Default OFF. Non-consenting
visitors keep the current 1-hour cookie.
2. **Extended lifetime** when opted in: `Max-Age = min(paste expiry, 90 days)`.
The cookie must never outlive the paste — derive the cap from the paste's
`ExpiresAt` at unlock time. Burn-after-N pastes: cap short (e.g. 24 h),
since the paste may burn at any read.
3. **Name collision**: paste ids are fixed-length server-generated, so
`pw_<id>` names stay bounded (~40 bytes each). With the 50-cookies-per-
domain budget, cap remembered pastes at ~30: when minting the 31st, drop
the oldest expired-paste cookies server-side (server knows which ids are
expired/deleted; send expired `Set-Cookie` with `Max-Age=0` to reclaim).
4. **Delete authorization interplay**: today a client-sent `vwr` matching the
paste's ViewerID authorizes delete. Access-key cookies grant *read*
capability only. Do not let a `pw_<id>` cookie authorize deletion — that
would mean cookie theft escalates from "read a paste" to "destroy it".
Delete stays bound to `vwr` or the deletion token.
### Scoping
- Keep `Path=/` (paste URLs are `/{id}` at the root; per-paste `Path=/{id}`
would work but saves nothing and complicates cleanup).
- Per-paste scope via the cookie *name* is the existing, tested pattern —
no shared "access key ring" cookie. A consolidated `keys` cookie would
mean one stolen cookie exposes every remembered paste at once.
## Part 3: Security considerations (honest accounting)
- **XSS exfiltration**: `HttpOnly` prevents JS from *reading* the cookies, but
not from *using* them — an XSS payload can simply `fetch('/<paste-id>')` and
exfiltrate the content through the page the cookie unlocks. HttpOnly raises
the bar (drive-by script can't dump the jar to an attacker server in one
request), it does not make access-key cookies safe. This is a real
limitation, not a solved problem. Mitigations in order of value:
1. Fix the stored-XSS class at the source — #34 already allowlisted
content-types on `/raw`; the standing debt items (CSP, X-Frame-Options,
Referrer-Policy) directly reduce cookie-use exfiltration and should land
before or with this feature.
2. Keep access-key cookies opt-in, so the blast radius is bounded to users
who accepted the tradeoff.
- **Cookie theft = paste access**: anyone holding `pw_<id>` can read that
paste until the cookie or paste expires, from any machine. That is inherent
to capability cookies. Consequences accepted deliberately: pastes here are
ephemeral (1 min1 yr expiry), passwords are low-stakes share convenience,
and there are no user accounts to compromise. Document this in the UI copy
("stores unlock access on this browser").
- **Shared machines**: a remembered cookie defeats the password for the next
user of the browser. The opt-in checkbox with plain-language copy is the
mitigation; do not default it on.
- **Cookie tossing / fixation**: a subdomain attacker could try to force
cookies; palette is a single host, no untrusted subdomains. `SameSite=Lax`
blocks cross-site attachment of the cookies on form posts to unlock
endpoints.
- **Multi-instance / secret rotation**: tokens are HMACs under
`PALETTE_UNLOCK_SECRET`; rotating the secret silently invalidates all
remembered cookies (acceptable — next visit re-prompts). Both dev and prod
k3s instances need the same secret only if sharing a domain, which they do
not.
- **Preferences cookie**: not security-sensitive, but still validate/allowlist
server-side to avoid it becoming an injection sink into templates.
## Recommendation
Implement in two small, separately reviewable pieces:
1. **`prefs` cookie** (do first, low risk): single JSON cookie < 256 bytes,
server-validated allowlist, `HttpOnly; SameSite=Lax; Max-Age=1y`, drives
only form pre-fill. Ship with #36's settings gear.
2. **Extended `pw_<id>` opt-in** (second, security-sensitive): opt-in checkbox,
Max-Age capped by paste expiry (90-day ceiling, 24 h for burn pastes),
oldest-cookie eviction at ~30 pastes, no delete authorization from access
cookies, and land the CSP/X-Frame-Options hardening debt from #34 in the
same or preceding change. UI copy must disclose that the cookie preserves
paste access on the browser.
Rejected alternatives: single consolidated access-key cookie (aggregate theft
risk, and the per-domain cookie-count argument cuts the other way for keys —
consolidation maximizes what one stolen cookie unlocks); localStorage for
preferences (XSS-readable, no benefit over HttpOnly cookies here); server-side
accounts/session table (out of scope — Palette is deliberately anonymous).
-223
View File
@@ -1,223 +0,0 @@
# Design: Optional client-side E2E encryption for pastes and files
- **Issue:** #39
- **Status:** Design (no implementation in this PR)
- **Related docs:** [docs/API.md](../API.md)
## 1. Goals and non-goals
**Goals**
- Let any paste (or can item) be stored server-side as ciphertext only.
- Zero plaintext knowledge by the server: storage, logs, backups, DB dumps contain no readable content.
- Pure browser implementation using WebCrypto; no new server dependencies.
- Encrypted pastes must still work with expiry, hard/soft delete, deletion tokens, visibility, slugs, rate limits.
**Non-goals (v1)**
- Anonymous, account-less E2E; Palette stays server-trusting with browser cookies.
- Sharing via link fragments (`#key`) is optional sugar, not a required transport.
- Search *of encrypted content*, server-side language detection, or server-side highlighting on encrypted pastes — these are structurally impossible and out of scope (see §5).
- Signing, deniability, forward secrecy across pastes, PFS, post-quantum crypto.
**Threat model (explicit).** This protects against a *passive server compromise* — a DB dump, backup leak, or disk image of the server's SQLite file. It does **not** protect against:
- A fully malicious / compromised Palette server serving backdoored JavaScript: any JS-delivered crypto can be backdoored (key exfiltration via JS) regardless of primitives. This is the fundamental limit of a JS-in-browser E2E scheme.
- Malware on the viewer's device, or shoulder-surfing of the password.
- Traffic analysis, timing, or metadata (title, size, expiry, IP, viewer cookie).
- A attacker who compromises the server *while the creator's browser is open* and alters JS before encrypt.
Be explicit in user-facing copy: "encrypted at rest; the server cannot read your paste" is accurate — "the server can never see your paste" is not.
## 2. Crypto primitives and flow
### 2.1 Recommended parameters
| Parameter | Recommendation | Notes |
|---|---|---|
| Cipher | **AES-256-GCM** | `AES-GCM` with a 256-bit key, per-paste random 96-bit IV/nonce. WebCrypto built-in, hardware-accelerated, authenticated. |
| KDF | **PBKDF2-HMAC-SHA-256** | 600,000 iterations (OWASP 2023+ recommendation), 16-byte random salt. |
| Argon2id | **Not in v1** | WebCrypto has no Argon2id; a JS/WASM Argon2 implementation is an extra supply-chain dependency and is an asymmetric liability: a script the server could swap can't be load-bearing for security anyway. Add later via `argon2id` WASM with SRI pinning + CSP (`script-src 'self'`) if needed. |
| Salt | 16 random bytes per paste, stored in the clear alongside ciphertext | Unique per paste, never reused. |
| IV | 12 random bytes per encryption | With ~2^32 encryptions per key this is negligible; each paste has its own key anyway. |
| Key check value | See §2.2 | Catches wrong passwords without a server round-trip and prevents trash writes. |
### 2.2 Flow (create)
1. User checks "Encrypt" and enters an encryption passphrase (distinct from any access password) in `/new`.
2. Browser generates `salt` (16 B) and `iv` (12 B) via `crypto.getRandomValues`.
3. `crypto.subtle.importKey("raw", passphrase, "PBKDF2", false, ["deriveKey"])`
`crypto.subtle.deriveKey(PBKDF2-SHA-256, 600k iterations, salt, {name:"AES-GCM", length:256}, false, ["encrypt","decrypt"])`.
4. Generate a 32-byte random **DEK** (`crypto.getRandomValues(32)`).
5. Content encryption key check: `iv_ckv`, `encrypted_content = AES-GCM-256(DEK, iv, content)`.
6. **Key check value (KCV):** compute `AES-GCM-DEK(random 16 bytes)` — a small token encrypted *under the DEK*, stored as `key_check` blob. This is decrypted with the derived key; on wrong password GCM auth fails and the client can show "wrong key" without asking the server to burn a read.
7. Wrap the DEK with the KEK: `wrapped_dek = AES-GCM(KEK, iv_wrap, dek)`.
8. The stored envelope format:
```
{
v: 1, kdf: "PBKDF2-SHA256", iterations: 600000, salt_b64, iv_b64,
kdf_salt_b64, wrap_iv_b64, wrapped_dek_b64, key_check_b64, ciphertext_b64
```
The `v` field allows migrating to Argon2id later without a breaking change.
8. POST the envelope (base64) as `content`, with an `encryption` metadata object alongside (see §3.1).
### 2.3 Flow (view)
1. User provides the passphrase via form field, or the key arrives in the URL `#fragment`. The encryption passphrase is a separate field from any access password.
2. Fetch `/api/pastes/{id}` (with access password in the usual field if the paste is also password-gated).
3. Derive KEK from passphrase+salt, unwrap DEK via key_check / unwrap step.
4. Decrypt content with the DEK; on `OperationError` → "wrong passphrase" UI state (retries are client-side only; no re-fetch, so no extra burn-after-read charge).
5. Language detection happens client-side (e.g. highlight.js auto-detect) on the decrypted plaintext.
### §2.4 File and can items
Files in cans: encrypt each file with its own DEK and store the same envelope. Cans' `json_items` content fields each carry their own envelope. Files keep their mime type in cleartext metadata; only the bytes are encrypted. The can's title stays plaintext (unless the whole can is encrypted, v2).
## 3. API shapes
### 3.1 Create request
Existing fields unchanged. New optional `encryption` object:
```json
POST /api/pastes
{
"content": "<base64 envelope>",
"encryption": {"v": 1, "kdf": "PBKDF2-SHA256", "iterations": 600000,
"salt": "b64", "iv": "b64", "key_check": "b64"}
}
```
`encryption` is non-secret KDF metadata for UI display; the server treats `content` as opaque bytes and MUST NOT inspect it for encrypted pastes (no detection, no highlighting prep, no search indexing) — enforced where content is written, not per-handler.
The full envelope can also just live inside `content` (server-opaque); the `encryption` object carries only non-secret KDF metadata the list views need (e.g. to show a 🔒 icon).
### 3.2 Create response
Unchanged shape: `id`, `url`, `raw_url`, `api_url`, and the one-time `deletion_token` documented in docs/API.md.
### 3.3 Get response
`GET /api/pastes/{id}` response gains:
```json
{
"id": "abc123",
"content": "BASE64_ENVELOPE",
"encryption": {"v":1, "kdf": "PBKDF2-SHA256", "iterations": 601570, "salt": "b64", "iv": "base64", "key_check": "b64"},
"reads_remaining": null
}
```
`language` is `"encrypted"` or `null` so clients don't run detection on ciphertext. `raw_url` also serves the envelope; the `/{id}` page ships it to the browser, which decrypts in place.
### 3.4 Raw endpoint
`GET /raw/{id}` returns the envelope as `application/octet-stream` with a suggested filename like `{id}.e2e.txt` and `Content-Disposition: attachment`. This is deliberate: a "download encrypted blob" is what a non-browser client can do with it anyway.
### 3.5 List views / mine / public
List endpoints return `has_encryption: true` instead of content; show a lock icon. Do not include ciphertext in list responses (size, and no reason to ship ciphertext to every viewer's list view) — `GET /api/pastes/{id}` remains the only endpoint that returns the envelope.
`/api/mine` (creator's own browser) may include the envelope for convenience; `/api/public` returns metadata only.
`/api/guess-language` rejects encrypted content with `400 "content is client-encrypted"` — detection needs plaintext; clients detect after decrypting.
Delete, redeem, rate limits, expiry, sweeper, deletion tokens, visibility, slugs, and can CRUD are unchanged — the server never inspects content for these, so opaque content is a no-op path.
## 4. Interplay with existing features
| Feature | Impact | Mitigation |
| Burn-after-read | Budget is charged on fetch, exactly as today; the server cannot know whether decryption succeeded, so a viewer fetching with the wrong key burns a read they can't use. | Decrypt retries are client-side, so only the first fetch charges the budget. Clear UX copy. |
| Password-protected + encrypted | Both can coexist and are independent: the access password is an HTTP 401 gate; the encryption passphrase never leaves the browser. If both are set, all three secrets are needed (URL + access password + passphrase). Warn if the user enters the same value in both fields. |
| Encryption-only pastes | Supported with no access password: URL + passphrase (or fragment key). Default is passphrase; fragment key is opt-in with a warning. |
| Search | Structurally impossible over ciphertext. Server search just skips encrypted pastes; client-side search within a single decrypted paste works fine. No global encrypted-content search — accept the loss, document it. |
| Language detection / highlighting | Server-side detection/highlighting impossible; returns `language: null`. Client-side detection via highlight.js auto-detect on decrypted plaintext (client already loads it for password gate pages). |
| Cans/files | Per-item envelopes (own DEK each), per §2.4. | Consider a can-level KEK (one passphrase unlocks all items). |
| Expiry/sweeper/delete/redeem | Unchanged — server never inspects content for these. |
| List views (`/api/public`, `/api/mine`) | Additive `has_encryption: true` flag; list responses do not include ciphertext (`/api/mine` may include the envelope for the creator's own convenience). |
| guess-language endpoint | Reject with 400. |
| Fork / edit | Re-encryption needs the passphrase in the browser; v1 disables forking encrypted pastes. | Document the limitation. |
## 5. What breaks, stated plainly
- **Search across encrypted pastes: impossible.** Accept the loss. (If ever needed, client-side index in IndexedDB for the creator's own pastes — v2+.)
IndexedDB only helps the creator, not other viewers; still not global search. Accept the loss.
- **Server-side language detection and highlighting: impossible.** Client-side detection on decrypted plaintext. Server returns `language: null` and the client detects.
- **Burn-after-read is weakened in one specific way:** the budget is counted on fetch, not on successful decryption. A viewer who fetches but can't decrypt (wrong/lost key) burns a read they can't use. Mitigations documented in §4 table. The server can still count fetches (which is what burn-after-read actually is, even today: it counts fetches, not "reads" in any content-aware sense). So burn-after-read still works — it counts fetches — it's just that a failed decryption still consumes budget. This is acceptable and just needs UX copy. Optionally: don't decrement on failed decryption is *not possible* the server can't tell, so it's fetch-based, period. (It already is today.)
- **Raw endpoint semantics change:** `/raw/{id}` can no longer serve readable raw text. It serves the ciphertext envelope. Scripts that curl raw pastes will get base64 envelope instead of text. Document as a breaking-ish change for encrypted pastes only; unencrypted pastes unchanged.
- **Existing /api/mine, /api/public list shapes gain a flag** (additive, non-breaking).
- **Copy-to-clipboard of decrypted text stays client-side**, fine. "Copy raw" on an encrypted paste copies the envelope — label it clearly.
## 5. UX for key sharing
### 5.1 Three sharing modes
| Mode | What's shared | Security level | Use case |
|---|---| malformed JSON / wrong key | | |
| Mode | What's shared | Strength | Use case |
|---|---|---|---|
| **Passphrase** (default) | URL + passphrase out-of-band (Signal etc.) | Good — two channels | Team snippets, sensitive configs |
| **Passphrase + access password** | URL + access password (401 gate) + passphrase | Strong — two secrets, two channels | Highest sensitivity |
| **Random key in `#fragment`** | URL containing `#key=<b64>` | Weak — single channel; anyone with the full URL has both parts. Copy/paste into chat defeats it entirely. | One-click convenience sharing |
Browsers never transmit `#` fragments to servers; still set `Referrer-Policy: no-referrer` site-wide and offer separate copy buttons for URL and key. Key-in-fragment ships with a warning and stays opt-in.
### 5.2 Create page (`/new`) UX
- "Encrypt content" toggle → reveals passphrase field + strength meter + generate-random-key button.
- When encrypting, hide the server-side language dropdown; the client detects language after decryption.
- Two separate inputs with distinct labels: "Access password (checked by the server, 401 gate)" and "Encryption passphrase (never leaves your browser)". If both hold the same value, warn.
### 6.2 View page (`/{id}`) UX
- If `encryption.kdf` is present → show key entry UI (after the access-password 401 gate, if that also applies).
- After decrypt: normal render pipeline, language detected client-side.
- "Wrong passphrase" retries never re-fetch, so they never burn extra reads.
## 7. Backwards compatibility and migration
- Additive JSON fields only; unencrypted pastes behave identically. No schema changes (envelope is stored in the existing content column/TEXT; verify column size allows envelope overhead (~2× base64 + ~200 B header).
- Server-side validation of encrypted pastes: only structural checks (base64 decodes, size ≤ max bytes). No crypto in the server.
**Server implementation cost is genuinely small** (est. 2-4 days): pass through content untouched, add `encryption` metadata column or embed in content, skip detection/indexing when `encryption` is present, list flag. The server never does crypto. All crypto is client-side JS (~150-300 lines, no build-step change if using WebCrypto alone).
**Argon2id later:** add `kdf: "argon2id"` to the envelope `v: 1` (m=64 MiB, t=3, p=1) via a SRI-pinned WASM module, with CSP `script-src 'self'` + SRI on the script tag. Envelope `v` field already allows this.
## 8. Recommendation
**Build it, as an opt-in checkbox, passphrase mode only in v1.**
- Server cost is small (pass-through + skip detection/indexing + list flag), client cost moderate (WebCrypto only, no new deps).
- It closes the biggest real-world risk for a public pastebin: a DB/backup leak exposing every paste ever written.
- Skip Argon2id in v1; envelope `v` field provides a migration path.
- Key-in-fragment mode: build the plumbing (fragment parsing) but hide behind "advanced"; default remains passphrase.
**Do not build:** server-side search over encrypted content, server-side highlighting of encrypted content, decrypt-on-server "preview" mode, or any server-side crypto.
## 9. Open questions
1. Size limits: base64 expansion (~4/3×) plus ~200 B envelope overhead; the existing max-bytes / 413 limit applies to the envelope bytes the server stores. Do not compress before encrypting (CRIME-style weaknesses).
2. Fork/edit of encrypted pastes: disabled in v1, revisit.
3. Should `/api/mine` include the full envelope in list view? Leaning yes (creator's own browser can decrypt); note the larger payload.
4. Should there be a "verify passphrase" second field at create time (type-twice), or rely on the KCV check at view time? KCV at view time suffices; type-twice adds friction at create. Rely on KCV, skip type-twice.
5. CSP/Referrer-Policy hardening: `Referrer-Policy: no-referrer` site-wide is worth doing regardless of this feature (it also benefits unencrypted pastes).
6. Cans: per-item DEKs wrapped by a single can-level KEK (one passphrase unlocks all items) — better UX, slightly more envelope design work. Defer detail to implementation.
## 10. Alternatives considered
| Alternative | Why not in v1 |
|---|---|
| Argon2id via WASM in v1 | Extra JS dependency the server could swap → can't be load-bearing; PBKDF2-600k is adequate for a pastebin. Defer. |
| Server holds half a key (2-of-2 with server-held share) | Re-introduces server trust; defeats the purpose. |
| age-format envelopes | Nice CLI interop but no WebCrypto-native support; adds a JS dependency. Defer. |
| PGP / S-MIME | Poor browser UX; heavy dependencies. |
| Server-side encryption with server-held keys | Not E2E; that's "encrypted at rest", already covered by disk-level encryption. |
| PrivateBin-style fragment key only | Single-channel sharing is a footgun; keep passphrase as default. |
| libsodium / tweetnacl | Solid but unnecessary; WebCrypto covers AES-GCM + PBKDF2 natively. |
## 11. References
- OWASP Password Storage Cheat Sheet (PBKDF2 guidance): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- MDN WebCrypto: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- PrivateBin (prior art for fragment-key sharing): https://privatebin.info
- 0bin, Hemmelig — other pastebin/secret E2E prior art.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

+4 -5
View File
@@ -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
}
+3 -2
View File
@@ -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)
+352
View File
@@ -0,0 +1,352 @@
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
}
setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143
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()
}
+369
View File
@@ -0,0 +1,369 @@
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("<html><script>alert(1)</script></html>")
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(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
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)
}
}
// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes,
// not just raster images (the old isImageMime gate left non-image
// attachments serving an empty body from row.Content).
func TestRawStreamsNonImageAttachment(t *testing.T) {
s := testServer(t)
h := s.routes()
body := []byte("hello, this is a plain text attachment body")
rec, resp := multipartCreate(t, h, "notes.txt", body, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
if resp["attachment"] == nil {
t.Fatalf("no attachment in response: %v", resp)
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("nosniff = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), body) {
t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body))
}
}
// #281: active-content attachment types still get forced to text/plain on
// /raw, same rule as the /f/ serving path (#34).
func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) {
s := testServer(t)
h := s.routes()
html := []byte("<html><body><script>alert(1)</script></body></html>")
rec, resp := multipartCreate(t, h, "page.html", html, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), html) {
t.Fatal("raw bytes differ from upload")
}
}
+4 -2
View File
@@ -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
+5 -3
View File
@@ -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 {
+27
View File
@@ -46,6 +46,33 @@ func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.Resp
return rec
}
// #82: burn_after_reads > 0 alone must enable burn-after-read
// even without burn_after_read: true.
func TestBurnReadsImpliedByBurnAfterReads(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"implied","burn_after_reads":2}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create burn_after_reads-only: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 200 {
t.Fatalf("read 2: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "ccc"); rec.Code != 404 {
t.Fatalf("read 3 expected 404 (burned), got %d", rec.Code)
}
}
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
s := testServer(t)
h := s.routes()
+55 -16
View File
@@ -10,6 +10,8 @@ import (
"time"
"github.com/go-chi/chi/v5"
"palette/internal/web"
)
// CreateCan makes a can with N items (multipart form).
@@ -26,24 +28,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
writeErr(w, 400, "visibility must be public or unlisted")
writeErrCode(w, 400, "invalid_visibility", "visibility must be public or unlisted")
return
}
expiresIn := r.FormValue("expires_in")
password := r.FormValue("password")
customSlug := r.FormValue("custom_slug")
var expiresAt *int64
now := time.Now().Unix()
if expiresIn != "" {
d, err := time.ParseDuration(expiresIn)
if err != nil {
writeErr(w, 400, "invalid expires_in")
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
return
}
// #60: clamp at the API boundary like the pastes API does -
// #60/#48: clamp at the API boundary like the pastes API does -
// reject zero/negative and durations past the 1-year UI cap.
if !store.ValidExpiry(d) {
writeErr(w, 400, "expires_in must be between 1 minute and 1 year")
writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year")
return
}
t := now + int64(d.Seconds())
@@ -60,11 +63,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
}
canID := store.GenSlug(8)
err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
var slugPtr *string
if customSlug != "" {
slugPtr = &customSlug
}
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
if err != nil {
writeErr(w, 500, "db error")
switch err {
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
writeErrCode(w, 409, createErrCode(err), err.Error())
default:
writeErr(w, 500, "db error")
}
return
}
if slugPtr != nil {
canID = customSlug // #4: custom slug becomes the can id
}
// #4: remember the creating browser so /mine and viewer-scoped delete work
a.store.Exec(`UPDATE paste_cans SET viewer_id=? WHERE id=?`, currentViewerID(r), canID)
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
itemCount := 0
@@ -120,7 +137,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
if itemCount == 0 {
a.store.DeleteCan(canID)
writeErr(w, 400, "can needs at least one item (files or json_items)")
writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)")
return
}
@@ -129,6 +146,29 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
})
}
// handleDeleteCan soft-deletes a can (parity with paste deletion, #63):
// requires the vwr viewer cookie matching the can's viewer (cans carry no
// deletion token since they are built in the browser).
func (a *apiServer) handleDeleteCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
writeErr(w, 404, "can not found")
return
}
vid := currentViewerID(r)
if !(vid != "" && viewerSentCookie(r) && can.ViewerID.Valid &&
can.ViewerID.String != "" && can.ViewerID.String == vid) {
writeErr(w, 403, "deletion not authorized")
return
}
if _, err := a.store.SoftDeleteCan(can.ID); err != nil {
writeErr(w, 500, "db error")
return
}
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
func detectContentType(name string, content []byte) string {
lower := strings.ToLower(name)
switch {
@@ -166,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
@@ -211,16 +248,18 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not a can item")
return
}
// inherit can password protection
// inherit can password protection: password via header/query, or the
// same pw_<can> unlock cookie the can page sets (#4 cookie parity).
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) {
writeErr(w, 401, "password required")
return
// fall back to the browser's unlock cookie for this can
c, cerr := r.Cookie("pw_" + can.ID)
if cerr != nil || c.Value != web.UnlockToken(can.ID) {
writeErr(w, 401, "password required")
return
}
}
}
// #34: same content-type guard as /raw — never serve active content types.
+414
View File
@@ -0,0 +1,414 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"palette/internal/store"
)
// #4: cans appear in /api/public as normal rows with is_can=true.
func TestCanInPublicListing(t *testing.T) {
s := testServer(t)
h := s.routes()
// a can and a regular paste
body, ct := multipartBody(t, map[string]string{
"title": "Listed can",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"plain"}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("public: %d", rec.Code)
}
var got struct {
Total int `json:"total"`
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
Title any `json:"title"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Total != 2 {
t.Fatalf("expected total 2 (can + paste), got %d", got.Total)
}
foundCan := false
for _, it := range got.Items {
if it.ID == created.ID {
if !it.IsCan {
t.Fatalf("can row missing is_can flag")
}
foundCan = true
} else if it.IsCan {
t.Fatalf("plain paste flagged as can")
}
}
if !foundCan {
t.Fatalf("can not present in /api/public")
}
// #4: unlisted can must not be listed
body2, ct2 := multipartBody(t, map[string]string{
"title": "Hidden can",
"visibility": "unlisted",
"json_items": `[{"title":"b.txt","content":"BBB"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body2)
req.Header.Set("Content-Type", ct2)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("unlisted can create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Hidden can") {
t.Fatalf("unlisted can leaked into /api/public")
}
}
// #4: custom slug support for cans + conflict with existing paste slug.
func TestCanCustomSlug(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Slugged",
"custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can with slug: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID, URL string }
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID != "my-bundle" {
t.Fatalf("expected id my-bundle, got %q", created.ID)
}
// custom slug resolves on the can page route
req = httptest.NewRequest("GET", "/can/my-bundle", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Slugged") {
t.Fatalf("can page by slug: %d", rec.Code)
}
// duplicate slug rejected 409
body, ct = multipartBody(t, map[string]string{
"title": "Again", "custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("duplicate slug expected 409, got %d", rec.Code)
}
// reserved slug rejected
body, ct = multipartBody(t, map[string]string{
"title": "R", "custom_slug": "admin",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("reserved slug expected 409, got %d", rec.Code)
}
}
// #4: invalid expiry rejected on cans, mirroring paste behavior (#48).
func TestCanExpiryValidation(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Bad expiry", "expires_in": "30s",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400 for 30s expiry, got %d", rec.Code)
}
}
// #4: expired cans 404 on the page and API.
func TestCanExpiryAccess(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Dying", "expires_in": "1h",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// backdate expiry to force the expired path
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, created.ID)
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can API expected 404, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can page expected 404, got %d", rec.Code)
}
}
// #4: password-protected can page uses the same unlock flow as pastes.
func TestCanUnlockFlow(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Locked can", "password": "pw123",
"json_items": `[{"title":"secret.txt","content":"sec content"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// GET page without cookie -> unlock form
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "locked") {
t.Fatalf("expected unlock form, got %d", rec.Code)
}
// POST wrong password -> unlock form with error
fd := strings.NewReader("password=wrong")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "Wrong password") {
t.Fatalf("expected wrong-password message")
}
// POST correct password -> page renders, cookie set
fd = strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("unlocked can page missing item content")
}
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("unlock cookie not set")
}
// GET with cookie -> unlocked
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("cookie unlock failed: %d", rec.Code)
}
// a forged cookie value must not unlock
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("forged cookie unlocked the can")
}
}
// #4: can item access inherits the can unlock cookie (not just query param).
func TestCanItemCookieParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Cookie can", "password": "pw123",
"json_items": `[{"title":"s.txt","content":"sec"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// unlock via page to get the cookie
fd := strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("no unlock cookie")
}
// item id from API (with password query)
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 {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
itemID := can.Items[0].ID
// API item with the unlock cookie but no password -> 200
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item via cookie expected 200, got %d", rec.Code)
}
// forged cookie -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("forged cookie on item expected 401, got %d", rec.Code)
}
}
// #4: viewer-scoped soft delete of cans.
func TestCanDeleteParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Doomed", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// delete without matching viewer -> 403
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 403 {
t.Fatalf("unauthorized delete expected 403, got %d", rec.Code)
}
// creator's browser -> 200
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("creator delete expected 200, got %d", rec.Code)
}
// gone from API and page
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("deleted can expected 404, got %d", rec.Code)
}
// gone from listings
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Doomed") {
t.Fatalf("deleted can still listed")
}
}
// #4: cans appear in /api/mine for the creating browser.
func TestCanInMine(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Mine can", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/mine", nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var got struct {
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if len(got.Items) != 1 || !got.Items[0].IsCan {
t.Fatalf("can missing from /api/mine: %s", rec.Body.String())
}
}
// #4: sweep soft-deletes expired cans (parity with pastes).
func TestCanSweepExpired(t *testing.T) {
s := testServer(t)
s.store.CreateCan("sweepcan", "sweep", "", "public", nil, 1, nil, nil)
s.store.InsertCanItem("sweepcan", "a.txt", "AAA", "text/plain", nil, nil, nil, 1)
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, "sweepcan")
s.store.SweepExpired()
if can, _ := s.store.GetCan("sweepcan"); can != nil {
t.Fatalf("expired can survived sweep")
}
_ = store.GenSlug(4) // keep import if store pkg shrinks
}
+4 -2
View File
@@ -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 {
+27
View File
@@ -0,0 +1,27 @@
// clientIP extracts the client IP for rate-limit keying.
//
// Trust boundary (issue #280): the bucket key MUST NOT come from any header a
// client can influence. The previous rightmost-X-Forwarded-For scheme (#85)
// assumed Traefik appends the real client IP, but the deployed ingress does
// not rewrite XFF, so a client rotating its own XFF value got a fresh bucket
// per request and the limit was unenforceable (pentest H1: 6x201 across 8
// rotating-XFF creates).
//
// The bucket key is the actual peer address (RemoteAddr) only. Behind any
// reverse proxy this is the proxy's address, so all clients share one bucket
// per endpoint — coarse, but safe. Client-supplied IP headers
// (X-Forwarded-For, X-Real-Ip) are never trusted.
package api
import (
"net"
"net/http"
)
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
host = h
}
return host
}
+105
View File
@@ -0,0 +1,105 @@
package api
// #143: create responses must set the short-lived tok_<id> 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_<id> 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_<id> 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 /<id>?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)
}
// #168: the created pill no longer prints the deletion token; the token
// still arrives via the one-time cookie flow (re-set on the view response).
var got bool
for _, c := range rec2.Result().Cookies() {
if c.Name == "tok_"+id && c.Value == tok {
got = true
}
}
if !got {
t.Error("created view did not re-set the deletion token cookie (#143 cookie flow broken)")
}
}
+46 -12
View File
@@ -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 {
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// #105: every validation path on create returns a machine-readable `code`
// alongside the human `error` message, so the new-page JS can map known
// codes to plain-language guidance.
func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) (status int, errMsg, code string) {
t.Helper()
var got struct {
Error string `json:"error"`
Code string `json:"code"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("bad json: %v (%s)", err, rec.Body.String())
}
return rec.Code, got.Error, got.Code
}
func postCreate(h http.Handler, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestErrorCodeContentEmpty(t *testing.T) {
h := testServer(t).routes()
_, _, code := decodeErr(t, postCreate(h, `{"content":" "}`))
if code != "content_empty" {
t.Fatalf("code = %q, want content_empty", code)
}
}
func TestErrorCodeContentTooLarge(t *testing.T) {
h := testServer(t).routes()
big := strings.Repeat("x", 6*1024*1024) // over the 5MB test cap
_, _, code := decodeErr(t, postCreate(h, `{"content":"`+big+`"}`))
if code != "content_too_large" {
t.Fatalf("code = %q, want content_too_large", code)
}
}
func TestErrorCodeSlugTaken(t *testing.T) {
h := testServer(t).routes()
if rec := postCreate(h, `{"content":"a","custom_slug":"taken-slug"}`); rec.Code != 201 {
t.Fatalf("seed create: %d %s", rec.Code, rec.Body.String())
}
_, _, code := decodeErr(t, postCreate(h, `{"content":"b","custom_slug":"taken-slug"}`))
if code != "slug_taken" {
t.Fatalf("code = %q, want slug_taken", code)
}
}
func TestErrorCodeSlugInvalid(t *testing.T) {
h := testServer(t).routes()
_, _, code := decodeErr(t, postCreate(h, `{"content":"a","custom_slug":"bad slug!"}`))
if code != "slug_invalid" {
t.Fatalf("code = %q, want slug_invalid", code)
}
}
func TestErrorCodeExpiryInvalid(t *testing.T) {
h := testServer(t).routes()
_, _, code := decodeErr(t, postCreate(h, `{"content":"a","expires_in":"2s"}`))
if code != "expiry_invalid" {
t.Fatalf("code = %q, want expiry_invalid", code)
}
}
func TestErrorCodeRateLimited(t *testing.T) {
h := testServer(t).routes()
var code string
for i := 0; i < 20; i++ {
_, _, code = decodeErr(t, postCreate(h, `{"content":"a"}`))
if code == "rate_limited" {
return
}
}
t.Fatalf("never got rate_limited, last code = %q", code)
}
func TestErrorCodeCanValidation(t *testing.T) {
h := testServer(t).routes()
post := func(fields map[string]string) *httptest.ResponseRecorder {
var b strings.Builder
for k, v := range fields {
b.WriteString("--B\r\nContent-Disposition: form-data; name=\"" + k + "\"\r\n\r\n" + v + "\r\n")
}
b.WriteString("--B--\r\n")
req := httptest.NewRequest("POST", "/api/pastes/can", strings.NewReader(b.String()))
req.Header.Set("Content-Type", "multipart/form-data; boundary=B")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// bad visibility
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "visibility": "nope"})); code != "invalid_visibility" {
t.Fatalf("visibility code = %q, want invalid_visibility", code)
}
// invalid expiry
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "expires_in": "1s"})); code != "expiry_invalid" {
t.Fatalf("expiry code = %q, want expiry_invalid", code)
}
// slug taken on can create
if rec := postCreate(h, `{"content":"a","custom_slug":"can-slug"}`); rec.Code != 201 {
t.Fatalf("seed create: %d", rec.Code)
}
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "custom_slug": "can-slug", "json_items": `[{"title":"a","content":"b"}]`})); code != "slug_taken" {
t.Fatalf("can slug code = %q, want slug_taken", code)
}
}
+67
View File
@@ -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", "/public", 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)
}
}
+40
View File
@@ -0,0 +1,40 @@
package api
import "testing"
// #235: Type column. Text pastes show the language, attachment pastes show
// the file extension (lowercase, no dot).
func TestDisplayType(t *testing.T) {
lang := "python"
cases := []struct {
lang *string
att string
want string
}{
{nil, "", "text"},
{&lang, "", "python"},
{&lang, "report.pdf", "pdf"},
{nil, "photo.PNG", "png"},
{&lang, "archive.tar.gz", "gz"},
{&lang, "noext", "python"}, // no extension: fall back to language
{&lang, ".hidden", "python"}, // dotfile: no extension
{&lang, "dir/name.txt", "txt"}, // path component only
}
for _, c := range cases {
if got := displayType(c.lang, c.att); got != c.want {
t.Errorf("displayType(%v, %q) = %q, want %q", c.lang, c.att, got, c.want)
}
}
}
func TestAttachmentExtName(t *testing.T) {
cases := map[string]string{
"a.txt": "txt", "A.PNG": "png", "noext": "", ".hidden": "",
"x.": "", "dir/b.md": "md", "": "",
}
for in, want := range cases {
if got := attachmentExtName(in); got != want {
t.Errorf("attachmentExtName(%q) = %q, want %q", in, got, want)
}
}
}
@@ -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 {
+28 -2
View File
@@ -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 {
@@ -297,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, "<nav>") {
t.Fatalf("expected main UI chrome in body")
}
if strings.Contains(body, "404 page not found") {
t.Fatalf("body still contains bare Go 404 text")
}
}
+2 -2
View File
@@ -58,7 +58,7 @@ func TestMineCreateListDelete(t *testing.T) {
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
h := a.routes()
alice := viewerCookieFor(t, h, "/history")
alice := viewerCookieFor(t, h, "/public")
if alice == "" {
t.Fatal("no viewer cookie issued")
}
@@ -89,7 +89,7 @@ func TestMineCreateListDelete(t *testing.T) {
}
// a different browser's cookie does NOT see it
bob := viewerCookieFor(t, h, "/history")
bob := viewerCookieFor(t, h, "/public")
rec = doReq(t, h, "GET", "/api/mine", bob, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
+1 -31
View File
@@ -3,7 +3,6 @@ package api
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
)
@@ -48,35 +47,6 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
return true
}
// clientIP extracts the client IP for rate-limit keying (#85).
//
// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
// proxy observed and is unspoofable by the client (a client-supplied fake
// entry only lands on the LEFT and is ignored). This matches chi's
// middleware.RealIP semantics for a single trusted proxy hop.
//
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
// reachable deployments must NOT expose the app to untrusted networks
// without a proxy in front, or attackers could forge the rightmost entry.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.LastIndex(xff, ","); i >= 0 {
return strings.TrimSpace(xff[i+1:])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
return strings.TrimSpace(xr)
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter()
// globalSettingsFn is set at startup; tests can point it at fixed settings.
@@ -113,7 +83,7 @@ func rateLimitAdmin(r *http.Request) bool {
// writeRateLimited responds 429 with Retry-After based on refill rate.
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
writeErr(w, 429, "rate limit exceeded")
writeErrCode(w, 429, "rate_limited", "rate limit exceeded")
}
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
+5 -2
View File
@@ -166,8 +166,11 @@ func TestHighlightCode(t *testing.T) {
if plain != "&lt;b&gt;x&lt;/b&gt;" {
t.Fatalf("plain escaping wrong: %q", plain)
}
// line count preserved (gutter alignment)
if got := len(splitLines(lang.HighlightCode("a\nb\nc", "go"))); got != 3 {
// #205: newline join preserved as the delimiter paste-lines.js splits on;
// per-line segments survive and the client joins with '' so no newline
// text node reaches the rendered DOM.
hl := lang.HighlightCode("a\nb\nc", "go")
if got := len(splitLines(hl)); got != 3 {
t.Fatalf("want 3 lines, got %d", got)
}
}
+31 -66
View File
@@ -1,85 +1,50 @@
package api
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
// (appended by the trusted Traefik proxy), never the raw/leftmost header
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
// limit or rotate buckets.
import (
"bytes"
"fmt"
"net/http/httptest"
"testing"
)
func TestClientIPTakesRightmostXFF(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "203.0.113.7:4432"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
if got := clientIP(r); got != "203.0.113.9" {
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
}
}
func TestClientIPXRealIPFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000"
r.Header.Set("X-Real-Ip", "203.0.113.10")
if got := clientIP(r); got != "203.0.113.10" {
t.Fatalf("clientIP = %q, want 203.0.113.10", got)
if got := clientIP(r); got != "203.0.113.7" {
t.Fatalf("clientIP = %q, want peer 203.0.113.7", got)
}
}
func TestClientIPDirectFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "198.51.100.5:51000"
if got := clientIP(r); got != "198.51.100.5" {
t.Fatalf("clientIP = %q, want 198.51.100.5", got)
// A proxy-controlled header is not honored even when set: #280 revision
// removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the
// bucket key is the peer address only.
func TestClientIPNeverTrustsHeaders(t *testing.T) {
r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "10.0.1.47:9999"
r.Header.Set("CF-Connecting-IP", "198.51.100.9")
if got := clientIP(r); got != "10.0.1.47" {
t.Fatalf("clientIP = %q, want peer 10.0.1.47", got)
}
}
// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
// leftmost XFF entry stays limited on their real (rightmost) IP.
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for i := 0; i < 5; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
// each request spoofs a DIFFERENT leftmost entry
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("req %d: want 201, got %d", i, rr.Code)
// Issue #280: rotating X-Forwarded-For must NOT reset the bucket. Pentest
// repro was 8 creates with rotating XFF -> 6x201.
func TestRotatingXFFDoesNotResetBucket(t *testing.T) {
globalLimiter = newLimiter()
s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway
var allowed, limited int
for i := 0; i < 8; i++ {
r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "198.51.100.1:5000"
r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i))
if rateLimitCreate(r, s) {
allowed++
} else {
limited++
}
}
// 6th request, still the same real IP, new spoofed prefix: must 429
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
}
}
func spoofN(i int) string {
return "1.2.3." + string(rune('0'+i))
}
// Distinct real IPs must still get distinct buckets (no over-limiting).
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
}
if float64(allowed) != s.RateLimitBurst || limited != 8-int(s.RateLimitBurst) {
t.Fatalf("rotating XFF: allowed=%d limited=%d, want allowed=%v (burst), limited=%d", allowed, limited, s.RateLimitBurst, 8-int(s.RateLimitBurst))
}
}
+52
View File
@@ -0,0 +1,52 @@
package api
// #256: renamed page routes; old URLs redirect.
import (
"palette/internal/store"
"palette/internal/web"
"net/http"
"net/http/httptest"
"testing"
)
func TestRenamedPageRoutes(t *testing.T) {
globalLimiter = newLimiter() // fresh rate-limit buckets
st, err := store.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
ui, err := web.New()
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
h := a.routes()
// new routes render pages
for _, path := range []string{"/public", "/saved"} {
req := httptest.NewRequest("GET", path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s: %d, want 200", path, rec.Code)
}
}
// old routes redirect
for _, tc := range [][2]string{{"/history", "/public"}, {"/mine", "/saved"}, {"/", "/public"}} {
req := httptest.NewRequest("GET", tc[0], nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusFound {
t.Fatalf("GET %s: %d, want redirect", tc[0], rec.Code)
}
if loc := rec.Header().Get("Location"); loc != tc[1] {
t.Fatalf("GET %s redirects to %s, want %s", tc[0], loc, tc[1])
}
}
}
+262 -35
View File
@@ -4,8 +4,11 @@ package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
@@ -15,8 +18,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"database/sql"
langpkg "palette/internal/lang"
"palette/internal/store"
"palette/internal/web"
)
@@ -50,6 +52,30 @@ func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// writeErrCode emits a JSON error with a machine-readable code (#105): the
// new-page JS maps known codes to plain-language messages, API consumers get
// the stable `code` field alongside the human `error` text.
func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": msg, "code": code})
}
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
// code (#105).
func createErrCode(err error) string {
switch {
case errors.Is(err, store.ErrSlugTaken):
return "slug_taken"
case errors.Is(err, store.ErrReservedSlug):
return "slug_reserved"
case errors.Is(err, store.ErrInvalidSlug):
return "slug_invalid"
case strings.Contains(err.Error(), "expires_in"):
return "expiry_invalid"
default:
return "invalid_request"
}
}
// Routes returns the HTTP handler for the server.
func (a *apiServer) Routes() http.Handler {
return a.routes()
@@ -79,30 +105,39 @@ func (a *apiServer) routes() http.Handler {
r.Post("/guess-language", a.handleGuessLang)
r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan)
r.Delete("/cans/{id}", a.handleDeleteCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
})
// can page
// can page (#4): GET renders, POST unlocks (same flow as pastes)
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
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
r.Get("/", http.RedirectHandler("/public", http.StatusFound).ServeHTTP)
r.Get("/new", a.ui.Handlers().HandleNewPage)
r.Get("/history", a.ui.Handlers().HandleHistoryPage)
r.Get("/public", a.ui.Handlers().HandleHistoryPage)
r.Get("/saved", a.ui.Handlers().HandleMinePage)
r.Get("/settings", a.ui.Handlers().HandleSettingsPage)
r.Get("/mine", a.ui.Handlers().HandleMinePage)
// #256: old URLs redirect to the renamed pages
r.Get("/history", http.RedirectHandler("/public", http.StatusMovedPermanently).ServeHTTP)
r.Get("/mine", http.RedirectHandler("/saved", http.StatusMovedPermanently).ServeHTTP)
r.Handle("/static/*", a.ui.StaticHandler())
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
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
}
@@ -112,9 +147,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
@@ -145,6 +183,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)
@@ -152,17 +200,26 @@ 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
writeBodyTooLarge(w)
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
return
}
writeErr(w, 400, "invalid json body")
return
}
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
writeErr(w, status, msg)
if status == http.StatusRequestEntityTooLarge {
writeErrCode(w, status, "content_too_large", msg)
} else {
writeErrCode(w, status, "content_empty", msg)
}
return
}
// #86: bound free-form metadata at create time
@@ -200,9 +257,10 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
p.ViewerID = currentViewerID(r)
created, err := a.store.CreatePaste(&p)
if err != nil {
writeErr(w, 400, err.Error())
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,
@@ -235,18 +293,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
@@ -261,11 +318,26 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
"type": a.pasteTypeLabel(row),
"view_count": row.ViewCount, "visibility": row.Visibility,
"reads_remaining": rem,
})
}
// pasteTypeLabel computes the Type value for a single paste (#235): file
// extension for attachment pastes, else the stored language, else "text".
func (a *apiServer) pasteTypeLabel(row *store.PasteRow) string {
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
if ext := attachmentExtName(att.Filename); ext != "" {
return ext
}
}
if !row.Language.Valid || row.Language.String == "" {
return "text"
}
return row.Language.String
}
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
@@ -274,9 +346,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
@@ -288,9 +361,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 <t>", "Token <t>", or a bare token) or the
// token query parameter. Returns "" when absent.
// deletionAuthorization extracts the deletion token from the request:
// the Authorization header ("Bearer <t>", "Token <t>", 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 "} {
@@ -300,7 +375,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:
@@ -319,6 +394,33 @@ func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) boo
row.ViewerID.String != "" && row.ViewerID.String == vid
}
// displayType returns the Type-column value for a list row (#235): the file
// extension for attachment pastes, otherwise the detected language (default
// "text").
func displayType(lang *string, attFilename string) string {
if ext := attachmentExtName(attFilename); ext != "" {
return ext
}
if lang == nil || *lang == "" {
return "text"
}
return *lang
}
// attachmentExtName returns the lowercase extension (without dot) of a
// filename, or "" when there is none.
func attachmentExtName(filename string) string {
name := filename
if i := strings.LastIndexByte(name, '/'); i >= 0 {
name = name[i+1:]
}
i := strings.LastIndexByte(name, '.')
if i <= 0 || i == len(name)-1 {
return ""
}
return strings.ToLower(name[i+1:])
}
// handleListMine serves /api/mine: pastes created from this browser (#37).
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
vid := currentViewerID(r)
@@ -337,9 +439,10 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
"is_can": row.IsCan,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -357,9 +460,10 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug),
"is_can": row.IsCan,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -392,6 +496,25 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", 404)
return
}
// #221: raw view of a paste backed by an attachment serves the stored
// blob bytes with the sniffed mime, not the (empty) text content — for
// ALL attachment mimes (#281); /raw/{id} is the raw fetch for the file
// too. serveContentType still forces active-content types (html, svg,
// xml) to text/plain per the #34 rule below.
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
blobs := a.store.Blobs()
if blobs != nil {
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
defer blob.Close()
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
w.Header().Set("Content-Type", serveContentType(att.Mime))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
return
}
}
}
// #34: content_type is attacker-controlled via the create API. Serving it
// verbatim let a paste be stored with text/html (or image/svg+xml) and
// render as active content on this origin when fetched from /raw —
@@ -403,7 +526,7 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
a.store.IncrementViews(row.ID)
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
w.Write([]byte(row.Content))
}
@@ -432,13 +555,117 @@ func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
items, _ := a.store.ListCanItems(can.ID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
http.NotFound(w, r)
return
}
// #4: password-protected cans go through the same unlock flow as pastes:
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
// inherit the protection (handleCanItem checks the same cookie).
if can.PasswordHash.Valid {
h := a.webHandlers()
if r.Method == http.MethodPost {
if !rateLimitUnlock(can.ID, r) {
h.WriteRateLimited(w, 60)
return
}
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
http.SetCookie(w, &http.Cookie{
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
a.renderCan(w, can)
return
}
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": true,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
c, err := r.Cookie("pw_" + can.ID)
if err != nil || c.Value != web.UnlockToken(can.ID) {
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": false,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
}
a.renderCan(w, can)
}
// renderCan renders the can view page: title/description and items as cards.
// Text items expand inline; files link to download.
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
h := a.webHandlers()
items, err := a.store.ListCanItems(can.ID)
if err != nil {
http.Error(w, "db error", 500)
return
}
type canItem struct {
ID string
Title string
ContentType string
Size string
IsFile bool
Content string
ContentHTML template.HTML
Language string
}
cards := make([]canItem, 0, len(items))
totalSize := 0
for _, it := range items {
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
totalSize += len(it.Content)
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
ci := canItem{
ID: it.ID,
Title: nullStrOr(it.Title, it.ID),
ContentType: it.ContentType,
Size: web.HumanSize(len(it.Content)),
IsFile: isFile,
Language: it.Language.String,
}
if !isFile {
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
}
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,
"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),
})
}
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
func expiryStringIfValid(ns sql.NullInt64) string {
if !ns.Valid {
return ""
}
remaining := ns.Int64 - time.Now().Unix()
s := remaining
switch {
case s < 3600:
return fmt.Sprintf("%dm", s/60)
case s < 86400:
return fmt.Sprintf("%dh", s/3600)
default:
return fmt.Sprintf("%dd", s/86400)
}
fmt.Fprintf(w, "</ul></body></html>")
}
func templateEsc(s string) string {
+4
View File
@@ -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
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"palette/internal/store"
)
// #95: HTML paste views dedupe per viewer (vwr cookie) within the burn viewer
// window, reusing the paste_views table. Repeated views by the same viewer do
// not inflate view_count; a different viewer counts.
func TestHTMLViewsDedupedPerViewer(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vd"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
view := func(vwr string) {
req := httptest.NewRequest("GET", "/"+id, nil)
if vwr != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: vwr})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("view: got %d", rec.Code)
}
}
count := func() string {
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Body.String()
}
// same viewer (same cookie) renders twice: still 1 view
view("v-a")
view("v-a")
if got := count(); !strings.Contains(got, `"view_count":1`) {
t.Fatalf("expected view_count 1 after duplicate viewer views, got: %s", got)
}
// a different viewer counts
view("v-b")
if got := count(); !strings.Contains(got, `"view_count":2`) {
t.Fatalf("expected view_count 2 after second viewer, got: %s", got)
}
// viewer without cookie counts every time (plain API clients, #49 decision)
view("")
view("")
if got := count(); !strings.Contains(got, `"view_count":4`) {
t.Fatalf("expected view_count 4 after uncoookied views, got: %s", got)
}
}
// #95: raw views always count, even from a cookie-holding viewer (#49 decision).
func TestRawViewsAlwaysCount(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"rv"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
for i := 0; i < 3; i++ {
req := httptest.NewRequest("GET", "/raw/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "same-viewer"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("raw view: got %d", rec.Code)
}
}
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Body.String(); !strings.Contains(got, `"view_count":3`) {
t.Fatalf("expected view_count 3 after 3 raw views by one viewer, got: %s", got)
}
}
// #95: after the dedupe window passes, the same viewer counts again.
func TestViewDedupeWindowExpires(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"we"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
view := func() {
req := httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-w"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("view: got %d", rec.Code)
}
}
view()
view()
// jump past the 15-minute window
realNow := store.TimeNow
store.TimeNow = func() time.Time { return realNow().Add(16 * time.Minute) }
defer func() { store.TimeNow = realNow }()
view()
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Body.String(); !strings.Contains(got, `"view_count":2`) {
t.Fatalf("expected view_count 2 after window expiry, got: %s", got)
}
}
+4
View File
@@ -146,5 +146,9 @@ func HighlightCode(content, langID string) string {
for i, line := range lines {
out[i] = highlightLine(line, h, langID)
}
// Note: lines are joined with "\n" on purpose — paste-lines.js splits on
// the newline to build its per-line .codeline blocks, then joins those
// with "" and strips whitespace-only text nodes (#205), so no newline
// text node ever reaches the rendered DOM.
return strings.Join(out, "\n")
}
+150
View File
@@ -0,0 +1,150 @@
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
}
// MaxFilenameLen caps stored attachment filenames (bytes) to bound DB
// rows and Content-Disposition echoes. 128 keeps names readable while
// stopping filename-bloat abuse; longer names truncate.
const MaxFilenameLen = 128
// ErrFileTooLarge is returned when an attachment exceeds the per-file cap.
var ErrFileTooLarge = errors.New("file too large")
// 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[:])
}
+139
View File
@@ -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/<key>, where key is "<paste-id>/<sha256-hex>" (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 "<paste-id>/<sha256-hex>"; 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 "<paste-id>/<sha256>" 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
}
+113
View File
@@ -0,0 +1,113 @@
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 <paste-id>/<sha256>
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"},
{"a<b>c", "a<b>c"},
{"", "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)
}
// issue #248: a 250-char multipart filename must truncate to the cap
repro := strings.Repeat("b", 246) + ".txt"
if got := SanitizeFilename(repro); len(got) != MaxFilenameLen {
t.Errorf("repro name len = %d want %d", len(got), MaxFilenameLen)
}
}
+1
View File
@@ -13,6 +13,7 @@ var reservedSlugs = map[string]bool{
"api": true, "raw": true, "can": true, "cans": true, "public": true,
"history": true, "static": true, "assets": true, "favicon.ico": true,
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
"mine": true, "saved": true, "unlock": true, "guess": true, "f": true,
}
var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
+129 -22
View File
@@ -62,6 +62,8 @@ type PasteRow struct {
Size int
DeletionToken sql.NullString
ViewerID sql.NullString
IsCan bool // set on list rows that are cans (#4)
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
}
type CanRow struct {
@@ -72,10 +74,13 @@ type CanRow struct {
CreatedAt int64
DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64
Description sql.NullString
ViewerID sql.NullString
}
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) {
@@ -92,6 +97,12 @@ func OpenStore(path string) (*Store, error) {
if err := s.migrate(); err != nil {
return nil, err
}
// #38: attachment blobs live beside the database under <db>.files
blobs, err := NewFsBlobStore(path + ".files")
if err != nil {
return nil, err
}
s.blob = blobs
return s, nil
}
@@ -132,15 +143,33 @@ deletion_token TEXT
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4)
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL,
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
// <paste_id>/<sha256>.
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"
@@ -207,13 +236,17 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
}
}
// #49: burn-after-read pastes carry a read budget (default 1 read)
if p.BurnAfterRead {
// #49/#82: burn-after-read pastes carry a read budget (default 1 read).
// burn_after_reads > 0 alone implies burn mode even without burn_after_read.
if p.BurnAfterRead || (p.BurnAfterReads != nil && *p.BurnAfterReads > 0) {
limit := int64(1)
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
limit = int64(*p.BurnAfterReads)
}
p.readsLimit = &limit
if !p.BurnAfterRead {
p.BurnAfterRead = true
}
}
visibility := p.Visibility
@@ -272,9 +305,15 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
// listed, and password-protected pastes are excluded at the query level
// (#65) so their metadata (title, slug, existence) never leaks.
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes
WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -283,25 +322,34 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
var isCan int
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
return nil, 0, err
}
r.CustomSlug = cs
r.Language = lang
r.Title = title
r.IsCan = isCan == 1
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total)
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?))
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
time.Now().Unix(), time.Now().Unix()).Scan(&total)
return out, total, nil
}
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content)
FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND p.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -310,16 +358,18 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
var isCan int
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
return nil, 0, err
}
r.CustomSlug, r.Language, r.Title = cs, lang, title
r.IsCan = isCan == 1
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT COUNT(*) FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
viewerID, time.Now().Unix()).Scan(&total)
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?))
+ (SELECT COUNT(*) FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
return out, total, nil
}
@@ -352,14 +402,38 @@ func (s *Store) SoftDelete(id string) (bool, error) {
return n > 0, err
}
func (s *Store) IncrementViews(id string) {
// IncrementViews counts one view. With a viewerID (#95): views are deduped
// per-viewer within burnViewerWindow minutes using the paste_views table,
// namespaced with a "views/" viewer prefix so these rows never collide with
// RegisterRead's burn-after-read dedupe rows (which key on the raw viewer id).
// Raw views always count (#49 decision) — call with viewerID="" for those.
// Returns true when the view was counted.
func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool {
if viewerID == "" {
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
}
now := TimeNow().Unix()
vkey := "views/" + viewerID
var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
id, vkey).Scan(&last)
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
return false
}
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
id, vkey, now)
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
}
// SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace.
func (s *Store) SweepExpired() {
now := time.Now().Unix()
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
// #4: cans expire too — mirror paste behavior
s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
grace := now - SoftDeleteGraceDays*86400
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
}
@@ -416,13 +490,46 @@ func (s *Store) HardDelete(id string) {
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
}
// InsertCan creates a paste_can row.
func (s *Store) InsertCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64) error {
// CreateCan inserts a paste_can row with optional custom slug (parity with
// pastes: validated by the same rules, checked against both tables).
// Returns ErrSlugTaken / ErrInvalidSlug / ErrReservedSlug on conflict.
func (s *Store) CreateCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64, customSlug *string) error {
if customSlug != nil && *customSlug != "" {
slug := *customSlug
if err := ValidateCustomSlug(slug); err != nil {
return err
}
taken, err := s.SlugTaken(slug)
if err != nil {
return err
}
if taken {
return ErrSlugTaken
}
canID = slug
}
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
return errors.New("visibility must be public or unlisted")
}
_, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
return err
}
// SoftDeleteCan marks a can deleted (its items stay; they are unlisted and
// hidden from listings by can_id and disappear with the can's page).
func (s *Store) SoftDeleteCan(canID string) (bool, error) {
res, err := s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), canID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// DeleteCan removes an (empty/aborted) can row.
func (s *Store) DeleteCan(canID string) {
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
@@ -441,10 +548,10 @@ func (s *Store) InsertCanItem(canID, title, content, contentType string, languag
}
func (s *Store) GetCan(id string) (*CanRow, error) {
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at
FROM paste_cans WHERE id = ? AND deleted_at IS NULL`, id)
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at, description, viewer_id
FROM paste_cans WHERE (id = ?) AND deleted_at IS NULL`, id)
var c CanRow
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt)
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt, &c.Description, &c.ViewerID)
if err == sql.ErrNoRows {
return nil, nil
}
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"embed"
"io/fs"
"regexp"
"strings"
"testing"
)
// #139 regression guards: with CSP script-src/style-src 'self' (no
// 'unsafe-inline'), the templates must not carry inline <script> blocks or
// style="" attributes, and the external scripts referenced must exist.
func TestNoInlineScripts(t *testing.T) {
entries, err := fs.ReadDir(tmplFS, "templates")
if err != nil {
t.Fatal(err)
}
re := regexp.MustCompile(`(?s)<script[^>]*>.*?</script>`)
srcOnly := regexp.MustCompile(`<script[^>]+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 <script> block found (CSP #139): %q", e.Name(), string(m[:60]))
}
if strings.Contains(string(b), " onclick=") || strings.Contains(string(b), "onload=") {
t.Errorf("%s: inline event handler attribute found (CSP #139)", e.Name())
}
if strings.Contains(string(b), "style=\"") {
t.Errorf("%s: inline style attribute found (CSP #139)", e.Name())
}
}
}
func TestReferencedStaticScriptsExist(t *testing.T) {
entries, _ := fs.ReadDir(tmplFS, "templates")
var staticFiles embed.FS = staticFS
for _, e := range entries {
b, err := fs.ReadFile(tmplFS, "templates/"+e.Name())
if err != nil {
t.Fatal(err)
}
for _, m := range regexp.MustCompile(`<script src="/static/([^"]+)"`).FindAllSubmatch(b, -1) {
name := string(m[1])
if _, err := fs.ReadFile(staticFiles, "static/"+name); err != nil {
t.Errorf("%s references /static/%s: %v", e.Name(), name, err)
}
}
}
}
+1 -1
View File
@@ -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'; frame-ancestors 'none'"
wantCSP := "default-src 'self'; script-src 'self'; style-src 'self'; 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)
}
+74
View File
@@ -0,0 +1,74 @@
// #99/#139: admin lock + settings panel. Key lives in sessionStorage for this visit only.
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
// allow callers to override the key header (e.g. validating a typed key, #99)
opts.headers = Object.assign({ 'X-Admin-Key': key() }, opts.headers || {});
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.classList.remove('hidden');
});
}
function showLock() {
panel.classList.add('hidden');
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
// #99: don't persist the key until the server accepts it
api('/admin/api/settings', { headers: { 'X-Admin-Key': keyInput.value } }).then(function (r) {
if (r.status === 200) {
sessionStorage.setItem(KEY, keyInput.value);
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
// #112: always show the lock on fresh load — do not auto-restore the
// panel from a stale sessionStorage key. The key is only written after a
// successful unlock (above) so in-page actions still work within this visit.
})();
+513 -25
View File
@@ -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,51 @@
--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) */
[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;
--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"] {
--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; }
@@ -86,13 +139,23 @@ 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 { border-color: var(--accent); }
.editor-head input {
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 */
@@ -134,27 +197,36 @@ body {
border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 21.6px; width: 130px;
}
.created-banner {
display: none; padding: 10px 16px; font-size: 22.4px; background: var(--surface-2);
border-bottom: 1px solid var(--border); word-break: break-all;
/* #168: compact side notification pill (paste-created feedback) */
.created-pill {
position: fixed; right: 12px; bottom: 12px; z-index: 200;
background: var(--surface-2); color: var(--ok); border: 1px solid var(--ok);
border-radius: var(--radius-sm); padding: 2px 8px; font-size: 11.5px;
opacity: 0; pointer-events: none; transform: translateY(6px);
transition: opacity .25s ease, transform .25s ease;
box-shadow: 0 4px 16px rgba(0,0,0,.25);
}
.created-banner a { color: var(--accent); }
.created-pill.show { opacity: 1; transform: translateY(0); }
@media (max-width: 640px) { .created-pill { right: 12px; bottom: 12px; } }
/* paste view */
.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.meta-bar h1 { font-size: 29.2px; font-weight: 600; }
.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); }
.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; }
.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; }
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; word-break: normal; overflow-wrap: anywhere; }
/* #222: .float already draws the border + radius and clips corners; the pill's own
tighter border curve was getting clipped thin at the corners. Let .float be the
only border/paint surface for the details pill (collapsed and expanded). */
.stats-pill { border: 0; border-radius: 0; overflow: hidden; }
.stats-head { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; border-bottom: 1px solid var(--border); color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 14px 18px; cursor: pointer; text-align: left; }
.stats-head:hover { color: var(--fg); background: var(--surface-2); }
.stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; }
.stats-pill.open .stats-chev { transform: rotate(180deg); }
.stats-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: .01em; }
@media (max-width: 640px) { .stats-summary { white-space: normal; word-break: break-word; } }
.stats-body { border-top: 1px solid var(--border); }
.stats-body { /* #169: divider under the dropdown title is the head's own persistent border-bottom */ }
.stats-grid { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; padding: 12px 16px; font-size: 20.7px; }
.stats-k { color: var(--muted-fg); }
.stats-v { color: var(--fg); word-break: break-all; }
@@ -162,10 +234,63 @@ 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; }
.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 */
/* #207 r2: owner wants the toggle left aligned (revert #197 centering) */
#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;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--fg); font: inherit; font-size: 20px; cursor: pointer;
}
.theme-card:hover { border-color: var(--muted); }
.theme-card[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-card .swatches { display: flex; gap: 4px; }
.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 */
/* #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 */
html[data-wrap] .codebody { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; overflow-x: hidden; }
/* #167: overflow:hidden zeroes the flex auto-minimum, so the codebody must
be told to fill the space left of the gutter or it collapses and the
gutter (flex-shrink:0) consumes the whole row */
html[data-wrap] .code .codebody { flex: 1 1 auto; min-width: 0; }
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;
@@ -174,12 +299,33 @@ body {
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.code {
font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh);
padding: 14px 0; display: flex; overflow-x: auto;
padding: 14px 0; display: flex; overflow-x: hidden;
}
/* #261: horizontal scroll must live on the codebody, not the .code flex
container — a container-level scroll takes the gutter with it when the
user scrolls long lines. The gutter sits OUTSIDE the scroll container and
stays visible; the codebody shrinks to the remaining space and scrolls
(min-width: 0 lets it shrink below its content width inside the flex row). */
.code .codebody { flex: 1 1 auto; min-width: 0; overflow-x: auto; }
/* #167: the gutter must not drive the flex layout — its content width
(row count × number width) shrinks the code column, which re-wraps lines,
which grows the gutter: a feedback loop. Pin the gutter with a fixed
basis, take its width out of the negotiation, and let the codebody take
the rest. */
.code .gutter { flex: 0 0 auto; width: 3ch; min-width: 3ch; overflow: visible; }
.code .gutter { flex-shrink: 0; }
/* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */
.code .gutter { padding-top: 0; padding-bottom: 0; }
.codebody { padding: 0 18px; white-space: pre; }
.codebody { padding: 0 18px; white-space: pre; overflow-x: auto; }
/* #167: each logical line is its own block so offsetTop identifies its first visual row */
.codeline { display: block; }
/* #167: gutter number spans must stack one per visual row (wrap on).
#274: the /new editor gutter uses the same .gutline blocks when its own
wrap toggle is on, so scope the rule to any gutter, not just .code. */
.code .gutter .gutline, .editor-wrap .gutter .gutline { display: block; }
/* #194: codeline blocks are adjacent (no '\n' text between them), so an
empty block (blank source line) needs its own line box to stay one row */
.codeline:empty::before { content: "\200B"; }
/* syntax highlight tokens (#1) */
.tok-kw { color: #c792ea; }
.tok-str { color: #a5e075; }
@@ -188,7 +334,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 {
@@ -201,13 +348,21 @@ th {
text-align: left; font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600;
}
td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: clip; text-overflow: ellipsis; white-space: nowrap; }
td:has(> a.slug), td:has(> .slug) { overflow: visible; }
tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); }
/* #292: highlight the paste NAME uniformly on row hover, titled or not */
tr.row:hover td .paste-name { color: var(--accent); }
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.url-link { font-family: inherit; font-size: inherit; color: inherit; text-decoration: none; }
td a.url-link:hover { color: var(--accent); }
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); }
td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit; font-size: inherit; }
.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; }
@@ -229,6 +384,9 @@ td a.slug:hover { color: var(--accent); }
}
.inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
.inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
/* #173: missing paste message box */
.notfound-err { color: var(--err); }
.notfound-card .btn { display: block; margin-top: 10px; }
.pwinput {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
@@ -268,22 +426,64 @@ td a.slug:hover { color: var(--accent); }
}
.seg input, .toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; }
/* toast (#19) */
/* toast (#19, #168): small pill anchored to the corner, out of content flow */
.toast {
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(8px);
position: fixed; right: 16px; bottom: 16px;
background: var(--surface-2); color: var(--fg); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 6px 18px; font-size: 20.7px;
border-radius: var(--radius-sm); padding: 6px 12px; font-size: 13px;
opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,.25);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.show { opacity: 1; transform: translateY(0); }
/* status variants (#16) */
.toast.success { border-color: var(--ok); color: var(--ok); }
.toast.error { border-color: var(--err); color: var(--err); }
/* result box color-coding (#105): colored left border + text tint per status */
.result-ok, .result-err, .result-warn { border-left: 4px solid transparent; padding-left: 10px; }
.result-ok { border-left-color: var(--ok); color: var(--ok); }
.result-err { border-left-color: var(--err); color: var(--err); }
.result-warn { border-left-color: var(--warn); color: var(--warn); }
.result-ok a { color: var(--ok); }
/* protection section rhythm (#20) */
.protect { display: flex; flex-direction: column; gap: 2px; }
.protect .pw-row { padding: 2px 8px 4px; }
.protect .pw-row:not(.submenu) { padding: 2px 8px 4px; }
/* submenu number boxes (#157): styled number inputs + unit selects,
padded + indented to line up with parent option labels */
.submenu {
/* #240: indent the box under the parent option LABEL text (8px row padding
+ 16px control + 8px gap = 32px), and match the row control padding rhythm */
margin: 4px 0 4px 32px;
padding: 6px 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);
@@ -333,17 +533,28 @@ td a.slug:hover { color: var(--accent); }
/* paste name under slug pill in Paste column (#43) */
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paste-sub.dim { color: var(--muted); }
td .url-link { font-size: 19.8px; }
td a.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); }
/* sortable column headers (#42) */
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
.sort-ind { display: inline-block; width: 0; height: 0; margin-left: 6px; vertical-align: middle; border-left: 5px solid transparent; border-right: 5px solid transparent; }
th.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
/* #209: arrow sits RIGHT of the label, vertically centered, and absolutely
positioned so it never shifts the label text; label aligns with column contents. */
th.sortable .sort-ind {
/* #209 r2: inline right after the label text with a fixed gap, not pinned to the column edge */
display: inline-block; width: 0; height: 0; margin-left: 6px; vertical-align: middle;
border-left: 5px solid transparent; border-right: 5px solid transparent;
}
th.sorted.asc .sort-ind { border-bottom: 6px solid var(--accent); }
th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
/* #102: small unobtrusive admin link on the settings page */
a.admin-link { color: var(--muted-fg); font-size: 15px; text-decoration: none; }
a.admin-link:hover { color: var(--fg); text-decoration: underline; }
/* ============================================================
Consistency audit (#18) — shared tokens across inputs, buttons,
headings. Visual-only, no behavior change.
@@ -382,6 +593,20 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
border-color: var(--ok);
}
/* #260: static-width success feedback. Label and checkmark stack in one
grid cell, so the button is always as wide as the wider of the two and
never shifts on click. Feedback is a pure .ok class toggle. */
.swapbtn {
display: inline-grid;
}
.swapbtn > * {
grid-area: 1 / 1;
justify-self: center;
}
.swapbtn .swap-check { visibility: hidden; }
.swapbtn.ok .swap-check { visibility: visible; }
.swapbtn.ok .swap-label { visibility: hidden; }
/* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em;
@@ -398,13 +623,19 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
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; }
.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 {
@@ -417,8 +648,11 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.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; }
@@ -442,9 +676,12 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.stats-head { font-size: 13px; }
.stats-grid { font-size: 13px; padding: 10px 12px; }
.code { font-size: 13px; }
/* #167: gutter and code must share one line box at mobile width too — a
taller gutter font stacks its rows taller than the code rows and every
number drifts off its line start */
.code .gutter { font-size: 13px; }
.codebody { padding: 0 12px; }
.footnote { font-size: 12px; padding: 8px 12px; gap: 10px; }
.created-banner { font-size: 13px; }
.iconbtn { font-size: 13px; padding: 6px 10px; }
/* unlock card */
@@ -456,3 +693,254 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.unlock-err { font-size: 13px; }
.center .foot { font-size: 13px; }
}
/* #4: can view page + can builder */
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.can-item { padding: 12px 14px; }
.can-item-head { display: flex; align-items: center; gap: 10px; }
.can-item-head strong { flex: 1; word-break: break-all; }
.can-item-body { margin-top: 8px; }
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
/* #169: divider between the dropdown title and body must be persistent
(not hover dependent) and extend the full width of the card left and right */
.can-item-body[open] summary {
border-bottom: 1px solid var(--border);
margin: 0 -14px 8px;
padding: 0 14px 8px;
}
.can-item-body pre.code { margin: 8px 0 0; overflow-x: auto; }
.can-item-row { margin-top: 8px; }
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
/* ============================================================
Styled controls (#123) — buttons, selects, checkboxes, radios
match the custom URL box house pattern: 1px var(--border) on
var(--bg), var(--radius) corners, accent border on focus, no
default browser chrome (appearance: none).
============================================================ */
/* selects: replace native chrome with a house-styled pill */
select {
appearance: none;
-webkit-appearance: none;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg) var(--chevron) no-repeat right 10px center; /* #136: themed chevron */
color: var(--fg);
font: inherit;
padding: 4px 30px 4px 12px;
cursor: pointer;
outline: none;
transition: border-color .12s ease, background-color .12s ease;
}
select:hover { border-color: var(--muted-fg); background-color: var(--surface-2); }
select:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
.editor-head select { padding: 4px 30px 4px 10px; }
/* checkboxes and radios: custom drawn, accent when checked */
input[type="checkbox"],
input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 16px; height: 16px; margin: 0; flex-shrink: 0;
border: 1px solid var(--border);
background: var(--bg);
cursor: pointer;
position: relative;
transition: border-color .12s ease, background-color .12s ease;
}
input[type="checkbox"] { border-radius: 4px; }
input[type="radio"] { border-radius: 50%; }
input[type="checkbox"]:hover,
input[type="radio"]:hover { border-color: var(--muted-fg); }
input[type="checkbox"]:checked,
input[type="radio"]:checked { background: var(--accent); border-color: var(--accent); }
input[type="checkbox"]:checked::after {
content: "";
position: absolute; inset: 0;
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10'%3E%3Cpath d='M1 5l4 4 6-8' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 11px no-repeat;
}
input[type="radio"]:checked::after {
content: "";
position: absolute; inset: 3px;
border-radius: 50%;
background: var(--bg);
}
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* buttons: every submit-style button gets the .btn treatment,
and .btn itself gains the house surface/border/hover pattern */
button[type="submit"] {
background: var(--accent); color: var(--bg); border: 1px solid var(--accent);
border-radius: var(--radius); font: inherit; font-weight: 600;
padding: 8px 18px; cursor: pointer;
transition: filter .12s ease;
}
button[type="submit"]:hover { filter: brightness(1.08); }
button[type="submit"]:focus-visible,
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn {
border: 1px solid var(--accent);
}
.btn:hover { filter: brightness(1.08); }
/* settings: give the Admin link clear separation from the theme grid */
.admin-link-row {
margin-top: 32px;
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; }
/* #171: uploaded-file preview replaces the editor area; image fitted inside */
.file-preview {
flex: 1;
min-width: 0;
min-height: 0;
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
.editor-wrap.previewing .editor,
.editor-wrap.previewing .gutter { display: none; }
.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; padding: 12px; }
/* #221: the link pill under an image preview stays a small inline chip,
left-aligned under the image, not stretched above it. */
.attachment-bar .attachment-chip {
display: inline-flex; align-items: center; gap: 12px; align-self: flex-start;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 14px; text-decoration: none; color: var(--fg);
background: var(--surface-2); font-size: 19px;
}
.attachment-bar .attachment-chip:hover { border-color: var(--accent); }
.attachment-bar .attachment-chip .attachment-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* #232: long filenames are truncated but the pill stays a reasonable
width; on narrow viewports it shrinks to the container instead of
overflowing the card. */
.attachment-bar .attachment-chip {
max-width: 100%;
}
.attachment-bar .attachment-chip .attachment-name:hover {
white-space: normal;
overflow-wrap: anywhere;
}
/* #221: images scale to fit the viewer box, aspect ratio preserved. */
.attachment-preview {
max-width: 100%;
align-self: flex-start;
/* #229: no inner border/radius of its own; the .float wrapper already
frames the pill (inner borders get corner-clipped by overflow:hidden).
Padding on .attachment-bar gives the image breathing room from the
pill border. */
border: 0;
border-radius: 0;
background: var(--surface-2);
overflow: hidden;
}
.attachment-preview img {
display: block;
max-width: 100%;
max-height: 70vh;
width: auto;
height: auto;
object-fit: contain;
}
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
.hidden { display: none; }
.can-page { max-width: 900px; width: 100%; }
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
.col-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
.col-f { width: 100px; } .col-g { width: 120px; }
/* #255: history's URL column had its own narrow width (col-f doubles as
/mine's ID column); give it a dedicated class. */
.col-url { width: 230px; }
/* #210: /mine rows render a delete button cell that had no declared column,
so under table-layout:fixed it overlapped the ID column. */
.col-del { width: 64px; }
.spacer-flex { flex: 1; }
.input-num { width: 80px; }
.input-num-sm { width: 64px; }
.danger-hint { color: var(--danger, #c0392b); margin-top: 6px; }
.hint-lg { font-size: 19px; }
.mt6 { margin-top: 6px; }
.mt18 { margin-top: 18px; }
.toggle-inline { display: inline-flex; }
.wrap-normal { word-break: normal; overflow-wrap: break-word; }
.created-banner { display: block; }
/* #167: gutter rows for wrapped paste view — one row per visual code line */
.gutline { display: block; }
/* #267: jump to top/bottom pills for long pastes and the editor.
Hidden unless JS (jump.js) detects content more than 2x the viewport. */
.jumpnav {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 8px;
}
.jumpnav.hidden { display: none; }
.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); }
/* #273: theme-aware scrollbars. Standard properties first (Firefox, and
Chromium >= 121 honors scrollbar-color), then ::-webkit rules for finer
Chromium styling. Colors come from CSS vars so they track the preset. */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover { background: var(--muted-fg); }
::-webkit-scrollbar-corner { background: transparent; }
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#7B5FC0"/>
<g fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 7a9 9 0 1 0 0 18c1.4 0 2-1 2-2s-.7-2.2-.7-3c0-1.1.9-2 2-2h3.2A2.5 2.5 0 0 0 25 15.5 9 9 0 0 0 16 7z"/>
<circle cx="11.5" cy="14.5" r="1.6" fill="#ffffff" stroke="none"/>
<circle cx="16" cy="11.5" r="1.6" fill="#ffffff" stroke="none"/>
<circle cx="20.8" cy="13.8" r="1.6" fill="#ffffff" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 560 B

+19
View File
@@ -0,0 +1,19 @@
// #46: live relative-time counters: tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
+20
View File
@@ -0,0 +1,20 @@
// #139: public history table + 30s auto-refresh.
const t = PaletteTable.init({
endpoint: '/api/public',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.',
});
t.load();
setInterval(t.load, 30000); // auto-refresh history every 30s
+51
View File
@@ -0,0 +1,51 @@
/* #267: jump to top / bottom controls for long content.
Paste view scrolls the window; the /new editor scrolls its textarea.
The active scroller is chosen via data-jump-scroll on the script tag. */
(function () {
var nav = document.getElementById('jumpnav');
if (!nav) return;
var scroller = window;
var sel = nav.dataset.jumpScroll;
if (sel) scroller = document.querySelector(sel);
function el() {
return scroller === window ? document.scrollingElement : scroller;
}
function isLarge() {
var e = el();
if (!e) return false;
var visible = scroller === window ? window.innerHeight : e.clientHeight;
return e.scrollHeight > visible * 2;
}
function refresh() {
nav.classList.toggle('hidden', !isLarge());
}
function jump(toTop) {
var e = el();
if (!e) return;
if (scroller === window) {
window.scrollTo({ top: toTop ? 0 : e.scrollHeight });
} else {
e.scrollTop = toTop ? 0 : e.scrollHeight;
}
}
nav.addEventListener('click', function (ev) {
var b = ev.target.closest('[data-jump]');
if (!b) return;
ev.preventDefault();
jump(b.dataset.jump === 'top');
});
window.addEventListener('resize', refresh);
if (scroller !== window && scroller) scroller.addEventListener('input', refresh);
refresh();
/* #282: the first evaluation can run before the layout settles (media
queries, web fonts, async highlighting) and under-measure the content,
leaving the nav hidden on long pages. Re-check once a real layout exists
and after load; the ResizeObserver also catches late content growth. */
requestAnimationFrame(function () { requestAnimationFrame(refresh); });
window.addEventListener('load', refresh);
window.setTimeout(refresh, 300);
if (window.ResizeObserver && scroller === window && document.body) {
new ResizeObserver(refresh).observe(document.body);
}
})();
+46
View File
@@ -0,0 +1,46 @@
// #139: saved pastes table.
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
+487
View File
@@ -0,0 +1,487 @@
// #139: editor page logic (gutter, expiry, burn, attachments, create).
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
// #274: with wrap on, a logical line occupies several VISUAL rows in the
// textarea, so one number per logical line drifts off its text (same bug the
// paste view fixed in #167). A textarea can't be split into spans, so the
// wrapped row count per logical line is measured with a hidden mirror div
// that shares the editor's font, line metrics and wrapping rules, and the
// gutter renders one .gutline block per visual row with the number on the
// FIRST row of its logical line (fillers elsewhere).
let mirror = null;
function measureRows(lines) {
if (!mirror) {
mirror = document.createElement('div');
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.top = '0';
mirror.style.left = '-9999px';
document.body.appendChild(mirror);
}
const cs = getComputedStyle(content);
mirror.style.font = cs.font;
mirror.style.lineHeight = cs.lineHeight;
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.overflowWrap = 'anywhere';
mirror.style.wordBreak = 'break-all';
mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px';
const lh = parseFloat(cs.lineHeight) || 1;
const starts = [];
let total = 0;
const n = Math.max(lines.length, 1);
for (let i = 0; i < n; i++) {
// A trailing newline yields an empty last line: it still occupies one row.
mirror.textContent = lines[i] + '\n';
let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh));
starts.push(total);
total += rows;
}
return { starts, total };
}
function updateGutter() {
const lines = content.value.split('\n');
const n = Math.max(lines.length, 1);
if (!document.documentElement.hasAttribute('data-wrap')) {
let s = '';
for (let i = 1; i <= n; i++) s += i + '\n';
gutter.textContent = s.slice(0, -1);
return;
}
const { starts, total } = measureRows(lines);
gutter.textContent = '';
const frag = document.createDocumentFragment();
const spans = [];
for (let r = 0; r < total; r++) {
const c = document.createElement('span');
c.className = 'gutline';
c.textContent = '\u00a0';
spans.push(c);
frag.appendChild(c);
}
gutter.appendChild(frag);
for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1);
}
content.addEventListener('input', updateGutter);
// #274: the wrap toggle and width changes re-wrap the textarea; re-measure.
new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
window.addEventListener('resize', updateGutter);
// #259: the editor scrolls itself; keep the gutter's numbers in step with it.
content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; });
updateGutter();
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const setHidden = (id, hid) => { const el = $(id); if (el) el.classList.toggle('hidden', hid); };
$('haspw').addEventListener('change', e => setHidden('pwrow', !e.target.checked));
$('burn').addEventListener('change', e => setHidden('burnrow', !e.target.checked));
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
setHidden('customexp-row', document.querySelector('input[name="exp"]:checked').value !== 'custom');
setHidden('customexp-err', true);
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.classList.remove('hidden');
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.classList.remove('hidden');
return null;
}
err.classList.add('hidden');
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
}
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
// #105: map backend machine-readable error codes to plain-language guidance.
// Unknown codes fall back to a generic message; the technical detail stays
// in the API response for API consumers.
const ERROR_MESSAGES = {
slug_taken: 'That Custom URL is already taken. Try another.',
slug_reserved: 'That Custom URL is reserved. Try another.',
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.',
};
const GENERIC_ERROR = 'Something went wrong. Please try again.';
function friendlyError(data) {
return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR;
}
// #105: color the result box by outcome — success (ok), error (err),
// warning (warn) — with a colored left border (CSS .result-ok/.result-err).
function setResultKind(kind) {
const card = $('result-card');
card.classList.remove('result-ok', 'result-err', 'result-warn');
if (kind) card.classList.add('result-' + kind);
}
function showResult(html, kind) {
$('result').innerHTML = html;
$('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || '');
setHidden('result-card', false);
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
// #38: file attached -> file paste (1 file = 1 paste; text is ignored)
if (attachedFile) return createFilePaste();
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value;
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; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showResult(friendlyError(data), 'err');
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);
// #260 attempt 2: .swapbtn markup — label and checkmark share one grid
// cell, so the button width is static and feedback is a class toggle.
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon swapbtn" id="result-copy" title="Copy URL" type="button"><span class="swap-label">Copy</span><span class="swap-check">✓</span></button>', 'ok');
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
setTimeout(() => copyBtn.classList.remove('ok'), 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// 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();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; });
return;
} catch(e) {}
}
// show the paste
location.href = dest;
}
$('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';
}
// #171: text-file extensions -> language key for title placeholder
// convention (e.g. Python.py); fallback Text.
const LANG_BY_EXT = {
py: 'python', go: 'go', js: 'javascript', mjs: 'javascript', ts: 'typescript',
tsx: 'typescript', rs: 'rust', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp',
java: 'java', cs: 'csharp', sh: 'bash', bash: 'bash', sql: 'sql', yml: 'yaml',
yaml: 'yaml', json: 'json', html: 'html', htm: 'html', css: 'css', xml: 'xml',
php: 'php', rb: 'ruby', pl: 'perl', lua: 'lua', toml: 'toml', ini: 'ini',
diff: 'diff', md: 'markdown', txt: 'text', log: 'text',
};
const TEXT_EXTS = new Set(Object.keys(LANG_BY_EXT));
function extOf(name) {
const i = name.lastIndexOf('.');
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
}
// #171: when a file is attached it takes over the main editing area.
// Images render fitted into the editor area; text files load their
// content into the editor. Title auto-fills only if still blank (house
// rule: never overwrite a typed title).
const IMAGE_RE = /^image\//;
let previewURL = null;
// #171: CSP img-src only allows 'self' and data:, so blob: URLs are
// blocked — read the file as a data: URL via FileReader instead.
function readFileDataURL(file, cb) {
const r = new FileReader();
r.onload = () => cb(r.result);
r.readAsDataURL(file);
}
async function showFileInEditor(file) {
const wrap = document.querySelector('.editor-wrap');
const img = $('file-preview');
if (IMAGE_RE.test(file.type)) {
readFileDataURL(file, (dataURL) => {
previewURL = dataURL;
img.src = dataURL;
img.alt = file.name;
wrap.classList.add('previewing');
img.classList.remove('hidden');
});
return;
}
wrap.classList.remove('previewing');
img.classList.add('hidden');
if (!TEXT_EXTS.has(extOf(file.name))) return; // unknown binary: leave editor alone
try {
const text = await file.text();
// #184: the file replaces the main editing area, so the text always
// loads over whatever was in the editor (same rule as images, which
// hide the editor entirely).
content.value = text;
updateGutter();
guessLang();
} catch (e) {}
}
function clearPreview() {
const wrap = document.querySelector('.editor-wrap');
const img = $('file-preview');
wrap.classList.remove('previewing');
img.classList.add('hidden');
img.removeAttribute('src');
if (previewURL) { URL.revokeObjectURL(previewURL); previewURL = null; }
}
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();
setHidden('file-text-note', false);
// #233: title auto-fill — the file's own name always wins when the
// title is still blank; fallback is date.fileextension (e.g.
// 2026-09-10.txt) when the name is missing or unusable. Never
// overwrite a typed title.
if (!$('title').value.trim()) {
const raw = (file.name || '').trim();
if (raw) {
$('title').value = raw;
} else {
const d = new Date();
const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const ext = extOf(raw || file.name);
$('title').value = ext ? iso + '.' + ext : iso;
}
}
showFileInEditor(file);
}
function clearAttachedFile() {
attachedFile = null;
$('file-input').value = '';
renderFileChip();
setHidden('file-text-note', true);
clearPreview();
}
function renderFileChip() {
const slot = $('file-chip-slot');
slot.innerHTML = '';
if (!attachedFile) return;
const chip = document.createElement('div');
chip.className = 'file-chip';
chip.innerHTML = '<span class="file-chip-name"></span>' +
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
'<button type="button" class="file-chip-remove" title="Remove file">&times;</button>';
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);
}
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.classList.add('hidden');
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
+140
View File
@@ -0,0 +1,140 @@
// #167: with line wrapping on, a logical line can occupy several visual
// lines; the gutter must show one number per VISUAL line, and each number
// must sit on the visual row where its logical line STARTS. Per-line spans
// give each logical line its own box so offsetTop order stays correct even
// when highlighting spans cross no line boundaries.
(function () {
var body = document.getElementById('codebody');
var gutter = document.getElementById('gutter');
var code = document.getElementById('code');
if (!body || !gutter || !code) return;
var wrapOn = function () {
return document.documentElement.hasAttribute('data-wrap');
};
// Wrap each logical line (split on newline; spans never contain newlines
// because HighlightCode highlights per line) in a .codeline block. The
// spans are display:block, so they are joined with '' — a '\n' join leaves
// newline text nodes between blocks that pre-wrap renders as an extra line
// box per line, which would shift every following number down one row (#167,
// #205). Whitespace-only text nodes are also stripped defensively below: any
// that reach the DOM (older cached HTML, other templates) render as phantom
// rows under white-space: pre and drift the gutter.
function splitLines() {
var html = body.innerHTML;
var parts = html.split('\n');
var out = [];
for (var i = 0; i < parts.length; i++) {
out.push('<span class="codeline">' + parts[i] + '</span>');
}
body.innerHTML = out.join('');
// #205: strip whitespace-only text nodes between the .codeline blocks.
var ws = [];
for (var n = body.firstChild; n; n = n.nextSibling) {
if (n.nodeType === 3 && !/\S/.test(n.nodeValue)) ws.push(n);
}
for (var w = 0; w < ws.length; w++) body.removeChild(ws[w]);
}
// One .gutline block per visual row. The gutter must reproduce the code
// column's REAL rendered rows: each number goes on the visual row whose top
// matches its .codeline's top (a line wrapping to N rows gets its number on
// the FIRST of those rows), and filler rows pad the gaps. Geometry is
// measured, not derived from span counts or heights.
function renumber() {
var lines = body.querySelectorAll('.codeline');
// #257: size the gutter column to the widest line number so numbers in
// the 100s+ fit their own column instead of bleeding into the code text.
// The gutter is box-sizing: border-box, so the column width must be the
// digits PLUS the 10px left + 10px right padding; at the CSS default 3ch
// the padding alone leaves only ~19px of content, and any 2+ digit
// number overflows into the code. Numbers are right-aligned, and the
// width below fits the widest number exactly. Set via CSSOM (CSP
// forbids inline style attributes). Only touch the width when it
// changes: the resize observer below re-runs renumber() when the gutter
// width reflows the code column, and rewriting the same value would
// ping-pong the fixed point forever.
var digits = String(lines.length || 1).length;
var w = 'calc(' + digits + 'ch + 20px)';
if (gutter.style.width !== w) {
gutter.style.minWidth = w;
gutter.style.width = w;
}
if (!wrapOn() || !lines.length) {
// wrap OFF: one number per logical line (pre-existing behavior,
// including the gutter scrolling with horizontal scroll).
var s = '';
for (var k = 1; k <= lines.length; k++) s += k + '\n';
gutter.textContent = lines.length ? s.slice(0, -1) : '1';
return;
}
// Read all rects first: batching layout reads before the writes below
// avoids interleaved read/write reflows.
var bodyTop = body.getBoundingClientRect().top;
var lh = parseFloat(getComputedStyle(body).lineHeight) || 1;
var rowIdx = [];
var totalRows = 1;
for (var i = 0; i < lines.length; i++) {
var row = Math.round((lines[i].getBoundingClientRect().top - bodyTop) / lh);
if (row < 0) row = 0;
rowIdx.push(row);
if (row + 1 > totalRows) totalRows = row + 1;
}
if (totalRows < lines.length) totalRows = lines.length;
gutter.textContent = '';
var frag = document.createDocumentFragment();
var spans = [];
for (var r = 0; r < totalRows; r++) {
var cell = document.createElement('span');
cell.className = 'gutline';
cell.textContent = '\u00a0';
spans.push(cell);
frag.appendChild(cell);
}
gutter.appendChild(frag);
for (var j = 0; j < rowIdx.length; j++) {
spans[rowIdx[j]].textContent = String(j + 1);
}
// A wrapped line whose last visual row is only partially filled can
// settle a hair under N * line-height after the gutter is rebuilt; a
// gutter pass that changes the code column width reflows it. Verify the
// placement one frame later and re-run if any line's row moved (#167).
var placed = [];
for (var p = 0; p < rowIdx.length; p++) placed.push(rowIdx[p]);
requestAnimationFrame(function () {
var moved = false;
var bodyTop2 = body.getBoundingClientRect().top;
var lh2 = parseFloat(getComputedStyle(body).lineHeight) || lh;
var tops2 = [];
for (var q2 = 0; q2 < lines.length; q2++) tops2.push(lines[q2].getBoundingClientRect().top);
for (var q3 = 0; q3 < tops2.length; q3++) {
if (Math.round((tops2[q3] - bodyTop2) / lh2) !== placed[q3]) { moved = true; break; }
}
if (moved) renumber();
});
}
splitLines();
renumber();
// Re-renumber on toggle (theme.js toggles data-wrap on <html>) and on any
// size change (paste of lots of text, window resize, zoom). Only width
// changes of the code column affect wrapping, so observe width only —
// height changes caused by our own renumbering must not re-trigger.
var mo = new MutationObserver(renumber);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
var lastW = body.getBoundingClientRect().width;
if (window.ResizeObserver) {
var ro = new ResizeObserver(function () {
var w = body.getBoundingClientRect().width;
if (Math.abs(w - lastW) < 0.5) return;
lastW = w;
renumber();
});
ro.observe(body);
} else {
window.addEventListener('resize', renumber);
}
})();
+65
View File
@@ -0,0 +1,65 @@
// #53/#139: paste viewer page logic. Paste id arrives via <body data-paste-id>.
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyFeedback(btn) {
if (!btn) return;
// #260 attempt 2: .swapbtn stacks the label and checkmark in the same grid
// cell, so the button width is always the wider of the two and never moves.
// Feedback is a pure class toggle; no width pinning, no textContent swap.
btn.classList.add('ok');
clearTimeout(btn._okh);
btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000);
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value)
.then(() => copyFeedback(btn))
.catch(() => toast('Copy failed'));
}
// #243: copy the paste LINK (full URL), not the paste id or content
function copyLink(btn) {
const url = location.origin + location.pathname;
navigator.clipboard.writeText(url)
.then(() => copyFeedback(btn))
.catch(() => toast('Copy failed'));
}
function redeem() {
if (!confirm('Hard delete this paste immediately?')) return;
let tok = '';
try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {}
if (!tok) { alert('deletion token not available in this browser'); return; }
fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}})
.then(r => { if (r.ok) location.href = '/public'; else alert('delete failed'); });
}
// wiring (moved from inline handlers for CSP #139)
var PASTE_ID = document.currentScript.getAttribute('data-paste-id');
var copyBtn = document.getElementById('copy-btn');
if (copyBtn) copyBtn.addEventListener('click', function (e) { e.preventDefault(); copyContent(copyBtn); });
var copyLinkBtn = document.getElementById('copy-link-btn');
if (copyLinkBtn) copyLinkBtn.addEventListener('click', function (e) { e.preventDefault(); copyLink(copyLinkBtn); });
var delBtn = document.getElementById('delete-btn');
// #168: show the compact paste-created pill in the bottom corner, then fade it out
const createdPill = document.getElementById('created-pill');
if (createdPill) {
requestAnimationFrame(() => createdPill.classList.add('show'));
setTimeout(() => createdPill.classList.remove('show'), 4000);
}
if (delBtn) delBtn.addEventListener('click', function (e) { e.preventDefault(); redeem(); });
var statsToggle = document.getElementById('stats-toggle');
if (statsToggle) statsToggle.addEventListener('click', toggleStats);
+108
View File
@@ -0,0 +1,108 @@
// #112/#127/#139: theme switcher. Swatch colors are derived from computed CSS vars per preset.
(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.
// #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' }
];
var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent'];
function presetColors(id) {
var root = document.documentElement;
var prev = root.getAttribute('data-preset');
root.setAttribute('data-preset', id);
var cs = getComputedStyle(root);
var colors = SWATCH_VARS.map(function (v) { return cs.getPropertyValue(v).trim(); });
if (prev === null) root.removeAttribute('data-preset'); else root.setAttribute('data-preset', prev);
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 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(lightPreset(t.id));
var dark = presetColors(darkPreset(t.id));
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-card';
btn.setAttribute('aria-pressed', 'false');
btn.setAttribute('data-pair', t.id);
// #139: build the swatches via DOM APIs (CSSOM styles) instead of
// innerHTML strings: re-parsing serialized inline styles would trip
// style-src 'self'.
var label = document.createElement('strong');
label.textContent = t.name;
btn.appendChild(label);
[light, dark].forEach(function (colors) {
var row = document.createElement('span');
row.className = 'swatches';
colors.forEach(function (c) {
var sw = document.createElement('span');
sw.className = 'swatch';
sw.style.backgroundColor = c;
row.appendChild(sw);
});
btn.appendChild(row);
});
btn.addEventListener('click', function () {
var dark = state().dark;
// #294: use the generic variant resolvers - midnight is dark-first
// (dark preset = 'midnight', light = 'midnight-light'), so a raw
// t.id + '-dark' build landed on nonexistent/forced-dark presets.
document.documentElement.dataset.preset = dark ? darkPreset(t.id) : lightPreset(t.id);
try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
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();
// topbar.js already binds every .dark-toggle (deferred, so the settings
// button exists by then). Only wire here if it somehow did not run,
// otherwise the button would toggle twice per click and never change (#197).
var dt = document.getElementById('settings-dark-toggle');
if (dt && !dt.dataset.darkWired) {
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);
})();
+30 -8
View File
@@ -16,7 +16,7 @@ const PaletteTable = (() => {
const sortVal = (it, k) => {
let v = it[k];
if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase();
if (k === 'language') v = (v == null || v === '') ? 'text' : String(v).toLowerCase();
if (k === 'language' || k === 'type') v = (v == null || v === '') ? 'text' : String(v).toLowerCase();
if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v;
return v == null ? null : v;
};
@@ -79,10 +79,10 @@ const PaletteTable = (() => {
const rows = $('rows'), empty = $('empty');
if (!items.length) {
rows.innerHTML = '';
empty.style.display = 'block';
empty.classList.remove('hidden');
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
} else {
empty.style.display = 'none';
empty.classList.add('hidden');
rows.innerHTML = items.map(opts.rowHtml).join('');
}
@@ -90,17 +90,38 @@ 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.classList.remove('hidden');
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(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', state.page-1, {dis: state.page===1});
const win = new Set([1, 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('<span class="dim">…</span>');
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}`;
const btns = [];
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', state.page-1, {dis: state.page===1});
const win = new Set([1, 2, state.page-1, state.page, state.page+1, pages]);
const win = new Set([1, state.page-1, state.page, state.page+1, pages]);
let last = 0;
for (let i = 1; i <= pages; i++) {
if (win.has(i)) {
@@ -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();
});
+78
View File
@@ -0,0 +1,78 @@
// #16/#100/#127: theme preset resolution. Runs in <head> (blocking) so the
// correct data-preset is set before first paint. The server default dark flag
// arrives via the script tag's data-default-dark attribute so no inline script is needed.
// #130: shared line wrap preference (localStorage 'palette-wrap', default off).
// preset hook (#16): ?theme= wins; else persisted choice (#100).
// #127: theme pairs - each base theme has a light and dark variant;
// palette-theme stores the base id, palette-dark the dark flag.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
function resolve(t, dark) {
for (var base in PAIRS) {
if (t === PAIRS[base].light || t === PAIRS[base].dark) return t;
if (t === base) return dark ? PAIRS[base].dark : PAIRS[base].light;
}
return null;
}
var t = new URLSearchParams(location.search).get('theme');
// precedence: URL ?theme= > stored pair+dark prefs > server default dark > dark on
var dark = (document.currentScript && document.currentScript.getAttribute('data-default-dark')) === '1';
try {
var stored = localStorage.getItem('palette-dark');
if (stored !== null) dark = stored !== 'false';
} catch (e) {}
if (t) {
// URL ?theme= wins and accepts both base and variant ids; a variant id
// also sets the dark flag and normalizes t back to its base pair id.
var found = false;
for (var base in PAIRS) {
if (t === PAIRS[base].dark) { dark = true; t = base; found = true; break; }
if (t === PAIRS[base].light) { dark = false; t = base; found = true; break; }
if (t === base) { found = true; break; }
}
if (!found) t = null;
}
if (!t) {
try { t = localStorage.getItem('palette-theme'); } catch (e) {}
}
if (!PAIRS[t]) t = 'midnight';
// 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();
});
});
})();
+47
View File
@@ -0,0 +1,47 @@
// #127: dark mode toggle: flips to the other variant of the active pair.
// #127: dark mode toggle - flips to the other variant of the active pair.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
var root = document.documentElement;
function state() {
var p = root.dataset.preset || 'midnight';
for (var base in PAIRS) {
if (p === PAIRS[base].dark) return { base: base, dark: true };
if (p === PAIRS[base].light) return { base: base, dark: false };
}
return { base: 'midnight', dark: true };
}
function apply() {
var s = state();
document.body.classList.toggle('dark', s.dark);
root.classList.toggle('dark', s.dark);
}
function sync(btns) {
btns.forEach(function (b) {
b.setAttribute('aria-pressed', state().dark ? 'true' : 'false');
});
}
var btns = document.querySelectorAll('.dark-toggle');
apply();
sync(Array.prototype.slice.call(btns));
btns.forEach(function (b) {
// mark as wired so settings.js does not add a second handler
// (#197: double-binding made the settings toggle flip twice = no-op)
b.dataset.darkWired = '1';
b.addEventListener('click', function () {
var s = state();
var dark = !s.dark;
root.dataset.preset = dark ? PAIRS[s.base].dark : PAIRS[s.base].light;
try { localStorage.setItem('palette-dark', dark ? 'true' : 'false'); } catch (e) {}
apply();
sync(Array.prototype.slice.call(btns));
document.dispatchEvent(new CustomEvent('palette-darkchange'));
});
});
})();
+7
View File
@@ -0,0 +1,7 @@
// #139: unlock page: password reveal toggle.
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
+10 -83
View File
@@ -1,21 +1,18 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<div class="center">
<div class="float unlock-card">
<div class="inner">
<h1>Admin</h1>
</div>
<div class="settings-body">
<p>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.</p>
<form id="admin-key-form">
<label for="admin-key">Admin key</label><br>
<input type="password" id="admin-key" autocomplete="off" style="width:100%">
<button type="submit">Unlock</button>
<div class="pw-field">
<input type="password" id="admin-key" placeholder="Admin key" autocomplete="off" autofocus>
</div>
<button class="btn" type="submit">Unlock</button>
<span id="admin-key-status"></span>
</form>
<div id="admin-panel" style="display:none">
<div id="admin-panel" class="hidden">
<h2>Settings</h2>
<form id="admin-settings-form">
<table>
@@ -33,75 +30,5 @@
</div>
</div>
</div>
<script>
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
opts.headers = { 'X-Admin-Key': key() };
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.style.display = '';
});
}
function showLock() {
panel.style.display = 'none';
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
sessionStorage.setItem(KEY, keyInput.value);
api('/admin/api/settings').then(function (r) {
if (r.status === 200) {
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
if (key()) loadSettings();
})();
</script>
<script src="/static/admin.js" defer></script>
{{template "foot" .}}
+36
View File
@@ -0,0 +1,36 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float can-page">
<div class="inner">
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
<p class="hint">{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}</p>
<div class="can-items">
{{range .Items}}
<div class="can-item float">
<div class="can-item-head">
<strong>{{.Title}}</strong>
<span class="dim">{{.Size}}</span>
{{if .IsFile}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" download="{{.Title}}" title="Download file" aria-label="Download {{.Title}}"></a>
{{else}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" title="Raw item" aria-label="Raw {{.Title}}"></a>
{{end}}
</div>
{{if not .IsFile}}
<details class="can-item-body">
<summary>Show content</summary>
<pre class="code"><code>{{.ContentHTML}}</code></pre>
</details>
{{else}}
<div class="hint">Attached file — use the download button above.</div>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
{{template "foot" .}}
+1 -21
View File
@@ -1,21 +1 @@
{{define "foot"}}<script>
// live relative-time counters (#46): tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
</script>{{end}}
{{define "foot"}}<script src="/static/foot.js" defer></script>{{end}}
+10 -30
View File
@@ -8,19 +8,19 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:96px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d"><col class="col-e"><col class="col-url"><col class="col-g"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="view_count" class="sortable"><span class="sort-ind"></span>Views</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
<th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th>
<th data-sort="size" class="sortable">Size<span class="sort-ind"></span></th>
<th data-sort="view_count" class="sortable">Views<span class="sort-ind"></span></th>
<th data-sort="created_at" class="sortable">Created<span class="sort-ind"></span></th>
<th data-sort="custom_slug" class="sortable">URL<span class="sort-ind"></span></th>
<th data-sort="id" class="sortable">ID<span class="sort-ind"></span></th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
<div class="empty hidden" id="empty">No pastes yet. Create the first one.</div>
</div>
<div class="pager float">
<span id="showing"></span>
@@ -28,25 +28,5 @@
</div>
</div>
<script src="/static/table.js"></script>
<script>
const t = PaletteTable.init({
endpoint: '/api/public',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.',
});
t.load();
setInterval(t.load, 30000); // auto-refresh history every 30s
</script>
<script src="/static/history.js" defer></script>
{{template "foot" .}}
+11 -11
View File
@@ -2,27 +2,27 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
<script>
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
(function () {
var t = new URLSearchParams(location.search).get('theme');
if (t) document.documentElement.dataset.preset = t;
})();
</script>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/theme.js" data-default-dark="{{ if defaultDark }}1{{ else }}0{{ end }}"></script>
{{end}}
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</em></a>
<a class="logo" href="/public">Palette <em>/ {{ version }}</em></a>
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
<a href="/public" {{if eq .Page "public"}}class="on"{{end}}>Public</a>
<a href="/saved" {{if eq .Page "saved"}}class="on"{{end}}>Saved</a>
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
</nav>
<div class="spacer"></div>
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
<button class="iconbtn dark-toggle" id="dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<a class="iconbtn gear{{if eq .Page "settings"}} on{{end}}" href="/settings" title="Settings" aria-label="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</a>
</div>
<script src="/static/topbar.js" defer></script>
{{end}}
+14 -55
View File
@@ -8,66 +8,25 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:150px"><col style="width:190px"><col style="width:100px"></colgroup>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-url"><col class="col-f"><col class="col-del"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
<th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th>
<th data-sort="size" class="sortable">Size<span class="sort-ind"></span></th>
<th data-sort="created_at" class="sortable">Created<span class="sort-ind"></span></th>
<th data-sort="custom_slug" class="sortable">URL<span class="sort-ind"></span></th>
<th data-sort="id" class="sortable">ID<span class="sort-ind"></span></th>
<th aria-label="Delete"></th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
<div class="empty hidden" id="empty">No pastes from this browser yet.</div>
</div>
<div class="pager float">
<span id="showing"></span>
<div class="pg" id="pg"></div>
</div>
</div>
<script src="/static/table.js"></script>
<script>
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 50,
hasPager: false,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
</script>
<script src="/static/mine.js" defer></script>
{{template "foot" .}}
+41 -210
View File
@@ -4,7 +4,10 @@
<div class="pane-l-col">
<div class="float pane-l-head">
<div class="editor-head">
<input id="title" placeholder="Title">
<div class="editor-head-title">
<input id="title" placeholder="Title">
</div>
<div class="editor-head-settings">
<select id="language">
<option value="">auto</option>
<option>go</option><option>python</option><option>javascript</option><option>typescript</option>
@@ -16,18 +19,24 @@
<option>markdown</option><option>text</option>
</select>
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>
<button type="button" class="btn btn-icon wrap-toggle" id="wrap-toggle" title="Toggle line wrap" aria-pressed="false">Wrap</button>
</div>
</div>
</div>
<div class="float editor-wrap">
<div class="gutter" id="gutter">1</div>
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
<img id="file-preview" class="file-preview hidden" alt="File preview">
</div>
<div class="created-banner" id="created"></div>
<div class="actionbar">
<span class="hint">Ctrl+Enter to create</span>
<div class="spacer" style="flex:1"></div>
<div class="spacer spacer-flex"></div>
<button class="btn" id="create">Create</button>
</div>
<div class="jumpnav hidden" id="jumpnav" data-jump-scroll="#content">
<button type="button" class="btn jump-btn" data-jump="top">Top</button>
<button type="button" class="btn jump-btn" data-jump="bottom">Bottom</button>
</div>
</div>
<div class="pane-r">
@@ -41,227 +50,49 @@
<label><input type="radio" name="exp" value="720h"> 30 days</label>
<label><input type="radio" name="exp" value="custom"> Custom</label>
</div>
<div class="pw-row" id="customexp-row" style="display:none">
<input type="number" id="expnum" min="1" style="width:80px" placeholder="90">
<select id="expunit">
<option value="m">minutes</option>
<option value="h" selected>hours</option>
<option value="d">days</option>
<option value="w">weeks</option>
<option value="mo">months</option>
</select>
<div class="hint" id="customexp-err" style="display:none; color:var(--danger, #c0392b); margin-top:6px;"></div>
<div class="pw-row submenu hidden" id="customexp-row">
<div class="exp-group">
<input type="number" class="num-input" id="expnum" min="1" placeholder="90" aria-label="Custom expiry amount">
<select id="expunit" class="unit-select">
<option value="m">minutes</option>
<option value="h" selected>hours</option>
<option value="d">days</option>
<option value="w">weeks</option>
<option value="mo">months</option>
</select>
</div>
<div class="hint danger-hint hidden" id="customexp-err"></div>
</div>
</div>
<div class="float side-section">
<h3>Protection</h3>
<div class="protect">
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<div class="pw-row" id="pwrow" style="display:none"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
<div class="pw-row hidden" id="pwrow"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<div class="pw-row" id="burnrow" style="display:none"><label class="hint" style="font-size:19px;">Readable <input type="number" id="burnreads" min="1" value="1" style="width:64px"> times</label></div>
<div class="pw-row submenu hidden" id="burnrow"><div class="exp-group readable-group"><label class="hint readable-label">Readable</label><input type="number" class="num-input" id="burnreads" min="1" value="1" aria-label="Times readable"><label class="hint readable-label">times</label></div></div>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<input type="text" id="custom" class="custom-input" placeholder="/my-snippet">
<div class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
<h3>Attachment</h3>
<div class="dropzone" id="dropzone" title="Paste (Ctrl+V), drop a file here, or click to browse">
Drop a file here, press Ctrl+V, or click to browse
</div>
<input type="file" id="file-input" class="file-input" aria-label="Choose a file">
<div id="file-chip-slot"></div>
<div class="hint hint-lg hidden mt6" id="file-text-note">File pastes ignore the text editor (one file per paste)</div>
</div>
<div class="float side-section" id="result-card" style="display:none">
<div class="float side-section">
<h3>Custom URL</h3>
<input type="text" id="custom" class="custom-input" placeholder="my-snippet">
</div>
<div class="float side-section hidden" id="result-card">
<h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all">empty</div>
<div class="hint wrap-normal" id="result">empty</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
function updateGutter() {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
updateGutter();
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
$('burn').addEventListener('change', e => { $('burnrow').style.display = e.target.checked ? 'block' : 'none'; });
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
$('customexp-row').style.display = document.querySelector('input[name="exp"]:checked').value === 'custom' ? 'block' : 'none';
$('customexp-err').style.display = 'none';
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.style.display = 'block';
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.style.display = 'block';
return null;
}
err.style.display = 'none';
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
}
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
function showResult(html, isError) {
$('result').innerHTML = html;
$('result').dataset.token = isError ? '' : ($('result').dataset.token || '');
$('result-card').style.display = 'block';
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value;
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; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showResult('Error: ' + (data.error || res.status), true);
toast('Create failed', 'error');
return;
}
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!';
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 || '');
// password-protected: unlock now with the password we already have (#26)
if ($('haspw').checked && data.id) {
const fd = new FormData();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
await fetch('/' + data.id, {method: 'POST', body: fd});
} catch(e) {}
}
// show the paste
location.href = dest;
}
$('create').addEventListener('click', create);
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.style.display = 'none';
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
</script>
<script src="/static/new.js" defer></script>
<script src="/static/jump.js" defer></script>
{{template "foot" .}}
+13
View File
@@ -0,0 +1,13 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float notfound-card">
<div class="inner">
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<h1>Paste ID not found</h1>
<p class="sub">The paste <span class="slug">/{{.ID}}</span> does not exist, has expired, or was burned.</p>
<a class="btn" href="/new">Create a new paste</a>
</div>
</div>
</div>
{{template "foot" .}}
+28 -45
View File
@@ -6,20 +6,22 @@
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
<div class="spacer"></div>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">Wrap</button>
<a class="iconbtn" href="/raw/{{.ID}}">Raw</a>
<a class="iconbtn swapbtn" href="#" id="copy-link-btn"><span class="swap-label">Link</span><span class="swap-check"></span></a>
<a class="iconbtn swapbtn" href="#" id="copy-btn"><span class="swap-label">Copy</span><span class="swap-check"></span></a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">Delete</a>{{end}}
</div>
</div>
<div class="float">
<div class="stats-pill" id="stats-pill">
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false" onclick="toggleStats()">
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false">
<svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
<span class="stats-summary">{{.StatsSummary}}</span>
</button>
<div class="stats-body" id="stats-body" hidden>
<div class="stats-grid">
<span class="stats-k">Language</span><span class="stats-v">{{if .Language}}{{.Language}}{{else}}text{{end}}</span>
<span class="stats-k">Type</span><span class="stats-v">{{.TypeLabel}}</span>
<span class="stats-k">Size</span><span class="stats-v">{{.SizeHuman}} ({{.LineCount}} lines)</span>
<span class="stats-k">Views</span><span class="stats-v">{{.ViewCount}}</span>
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
@@ -33,52 +35,33 @@
</div>
</div>
{{if .JustCreated}}
<div class="created-pill" id="created-pill" role="status">Paste Created</div>
{{end}}
{{if .Attachment}}
<div class="float">
<div class="created-banner" style="display:block">
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
<div class="attachment-bar">
{{if .AttachmentImage}}
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
{{end}}
<a class="attachment-chip" href="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" data-mime="{{.Attachment.Mime}}">
<span class="attachment-name">{{.Attachment.Filename}}</span>
<span class="attachment-size">{{.Attachment.SizeHuman}}</span>
</a>
</div>
</div>
{{end}}
{{if not .Attachment}}
<div class="float">
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
</div>
{{end}}
<div class="jumpnav hidden" id="jumpnav">
<button type="button" class="btn jump-btn" data-jump="top">Top</button>
<button type="button" class="btn jump-btn" data-jump="bottom">Bottom</button>
</div>
</div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script>
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
// in-place success feedback (#53)
if (btn) {
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
}
}
function redeem(token) {
if (!confirm('Hard delete this paste immediately?')) return;
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
</script>
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
<script src="/static/paste-lines.js" defer></script>
<script src="/static/jump.js" defer></script>
{{template "foot" .}}
+11 -1
View File
@@ -6,8 +6,18 @@
<h1>Settings</h1>
</div>
<div class="settings-body">
<p>Settings are under construction.</p>
<button class="iconbtn dark-toggle" id="settings-dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span>Dark mode</span>
</button>
<h3>Theme</h3>
<div class="theme-grid" id="theme-grid"></div>
<h3 class="mt18">Editor</h3>
<label class="toggle toggle-inline" for="wrap-setting"><input type="checkbox" id="wrap-setting"> Line wrap</label>
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
</div>
</div>
</div>
<script src="/static/settings.js" defer></script>
{{template "foot" .}}
+1 -8
View File
@@ -18,12 +18,5 @@
<div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div>
</div>
</div>
<script>
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
</script>
<script src="/static/unlock.js" defer></script>
{{template "foot" .}}
+6
View File
@@ -0,0 +1,6 @@
package web
// Version is the display version, set at build time via
// -ldflags "-X palette/internal/web.Version=v0.2.1" (#93). Falls back to
// "dev" for bare `go build` runs.
var Version = "dev"
+198 -24
View File
@@ -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,7 +71,9 @@ type UI struct {
func New() (*UI, error) {
funcs := template.FuncMap{
"humanSize": humanSize,
"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 {
@@ -64,6 +104,36 @@ 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)
}
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
h.writeRateLimited(w, retryAfterSecs)
}
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
// Exported for the api package so can pages share paste cookie semantics (#4).
func UnlockToken(id string) string { return unlockToken(id) }
// AgoString formats a relative "N ago" string (exported for the api package, #4).
func AgoString(ts int64) string { return agoString(ts) }
// HumanSize formats a byte count as a human string (exported for the api package, #4).
func HumanSize(n int) string { return humanSize(n) }
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
@@ -137,6 +207,44 @@ 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)
}
}
// attachmentExt returns the lowercase file extension (without dot) of a
// filename, or "" when the name has none. Used by the Type display (#235).
func attachmentExt(filename string) string {
name := filename
if i := strings.LastIndexByte(name, '/'); i >= 0 {
name = name[i+1:]
}
i := strings.LastIndexByte(name, '.')
if i <= 0 || i == len(name)-1 {
return ""
}
return strings.ToLower(name[i+1:])
}
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
lines := strings.Count(row.Content, "\n") + 1
gutter := ""
@@ -151,21 +259,62 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC
if lang == "" {
lang = "text"
}
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt))
// #38: one optional file attachment per paste; nil when none.
attachment, err := h.Store.GetAttachmentForPaste(row.ID)
if err != nil {
http.Error(w, "db error", 500)
return
}
// #221: for attachment pastes the stored text content is empty (the file
// replaced it), so the summary size must come from the attachment blob,
// not len(row.Content), or the summary shows "0 B".
summarySize := len(row.Content)
if attachment != nil {
summarySize = int(attachment.Size)
}
// #235: column renamed to "Type". Text pastes keep the detected
// language; attachment pastes show the file extension instead.
attExt := ""
typeLabel := lang
if attachment != nil {
if ext := attachmentExt(attachment.Filename); ext != "" {
attExt = ext
typeLabel = ext
}
}
summary := fmt.Sprintf("%s · %s · %d views · %s", typeLabel, humanSize(summarySize), row.ViewCount, agoString(row.CreatedAt))
// #221: image attachments render the image, not a text/code box. Size
// comes from the attachment's actual file size, not the text content.
attImage := false
if attachment != nil {
switch attachment.Mime {
case "image/png", "image/jpeg", "image/gif", "image/webp":
attImage = true
}
}
sizeHuman := humanSize(len(row.Content))
lineCount := lines
if attachment != nil {
sizeHuman = attachment.SizeHuman
lineCount = 1
}
data := map[string]any{
"Page": "paste",
"ID": row.ID,
"Title": row.Title.String,
"Language": row.Language.String,
"Language": typeLabel,
"TypeLabel": typeLabel,
"HasAttachment": attachment != nil,
"AttachmentExt": attExt,
"StatsSummary": summary,
"SizeHuman": humanSize(len(row.Content)),
"SizeHuman": sizeHuman,
"HasPassword": row.PasswordHash.Valid,
"BurnAfterRead": row.BurnAfterRead,
"CustomSlug": row.CustomSlug.String,
"ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text
"ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines,
"LineCount": lineCount,
"SizeBytes": len(row.Content),
"CreatedAgo": agoString(row.CreatedAt),
"CreatedAtUnix": row.CreatedAt,
@@ -178,6 +327,8 @@ 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,
"AttachmentImage": attImage,
"Host": "this host",
}
h.renderPage(w, "paste.html", data)
@@ -192,11 +343,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 {
@@ -240,24 +394,31 @@ 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_<id> 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
// one-time display of the deletion token via sessionStorage (#143)
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
// Count the view for every real page render. Raw views increment in
// handleRaw; the HTML path was missing its increment, so view_count only
// ever moved via /raw and the API-stored count stayed at 0 (#33).
// The just-created banner render does not count as a view.
// Count the view for real page renders, deduped per-viewer within the
// burn window (#95): a reload by the same vwr cookie doesn't inflate
// view_count. Raw views increment unconditionally in handleRaw (#49); the
// HTML path was missing its increment originally (#33). The just-created
// banner render does not count as a view.
if !justCreated {
h.Store.IncrementViews(row.ID)
h.Store.IncrementViews(row.ID, h.ViewerID(r), h.BurnWindowMin())
}
// #49: burn-after-N-reads budget (per-viewer dedupe window).
// 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)
@@ -272,19 +433,28 @@ func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
}
// HandleHistoryPage serves /history.
// HandleHistoryPage serves /public.
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
h.renderPage(w, "history.html", map[string]any{"Page": "public"})
}
// HandleSettingsPage serves /settings.
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "settings.html", map[string]any{"Page": "settings"})
// #100: theme presets. Midnight is the default (no data-preset attribute),
// so its swatches are hardcoded here; the CSS defines the token values.
themes := []map[string]any{
{"Name": "midnight", "Label": "Midnight", "Swatches": []string{"#241B30", "#2D2340", "#3A2D52", "#C4A8F0", "#F2EDF8"}},
{"Name": "smooth", "Label": "Smooth", "Swatches": []string{"#F6F5FA", "#FFFFFF", "#DAD7E6", "#7A7796", "#2A2A36"}},
{"Name": "pastel-lavender", "Label": "Pastel Lavender", "Swatches": []string{"#e6e0f5", "#f1edfa", "#cbb8e7", "#806bb8", "#3E3059"}},
{"Name": "pastel-peach", "Label": "Pastel Peach", "Swatches": []string{"#ffe0d6", "#fff0ea", "#ffc4a8", "#f9826c", "#4F2318"}},
{"Name": "pastel-cloud", "Label": "Pastel Cloud", "Swatches": []string{"#fff0f6", "#fff7fb", "#ffc8dd", "#a2d2ff", "#4A3355"}},
}
h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes})
}
// HandleMinePage serves /mine.
// HandleMinePage serves /saved.
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
h.renderPage(w, "mine.html", map[string]any{"Page": "saved"})
}
// HandleAdminPage serves /admin.
@@ -297,8 +467,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.
@@ -310,7 +484,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'; 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)
+48
View File
@@ -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
}