Author SHA1 Message Date
fen 1e696b1cbb docs: remove leftover "Create a can" curl example for deleted endpoint
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 20s
2026-10-16 16:57:25 -05:00
fen e54fbc36ef Remove cans feature; add Secure flag to pw_/tok_ cookies
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 21s
Owner decision 2026-10-16: the cans feature (bundles of pastes, #4) was
half-integrated and has zero prod usage; removing it eliminates four audit
findings by deletion.

Removed:
- internal/api/cans.go and all can routes (/api/pastes/can, /api/cans/*,
  /can/{id}) plus handleCanPage/renderCan
- is_can from /api/mine and /api/public responses
- store layer: CanRow, can store funcs, paste_cans UNION in listings,
  cans sweep; reserved slugs 'can'/'cans' released
- templates/can.html, can CSS, is_can badges in history.js/mine.js
- cans tests (cans_test.go, cans_flow_test.go, cans_expiry_test.go,
  TestErrorCodeCanValidation)
- docs/api.md cans section, README/LICENSE/docker-compose mentions

Store migration: drops paste_cans only when empty (non-empty table is
kept and logged; prod verified clean 2026-10-16) and drops the dormant
pastes.can_id column. Covered by new TestMigrateDropsCansSchema.

Security hardening (audit findings): pw_<id> (unlock) and tok_<id>
(deletion-token) cookies now carry Secure: true in addition to
HttpOnly + SameSite=Lax; all deployments are HTTPS-only. cookie_set_test
now asserts Secure.

go vet ./... and go test ./... pass.
2026-10-16 16:50:40 -05:00
fen 651bc0cb86 Add API documentation (docs/api.md) (#305)
CI / test (push) Successful in 20s
CI / docker (push) Successful in 44s
2026-10-16 20:46:45 +00:00
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 (pull_request) Skipped
CI / docker (push) Successful in 41s
CI / test (pull_request) Successful in 26s
2026-09-18 16:23:38 +00:00
fen ce14c6b713 #296 r3: shrink URL column, render URL values as plain text
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 26s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 26s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 26s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 25s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 26s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 31s
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 / docker (pull_request) Skipped
CI / docker (push) Successful in 46s
CI / test (pull_request) Successful in 25s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 25s
# 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 (pull_request) Skipped
CI / docker (push) Successful in 46s
CI / test (pull_request) Successful in 25s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 25s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 25s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 25s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 27s
2026-09-18 01:21:27 +00:00
fen f3fe2335d4 Release v0.5.0: README refresh
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 27s
- 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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 24s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 24s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 24s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 22s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 22s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 25s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 22s
2026-09-17 14:30:23 -05:00
fen ee0cd7dcbd new page: sync editor gutter with textarea scroll (#259)
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 22s
2026-09-17 14:30:03 -05:00
fen a4e1abfaae #260: replace Success! feedback text with a checkmark so buttons do not resize
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 21s
2026-09-17 14:29:02 -05:00
fen d25e20733e #255: widen history URL column with dedicated col-url class
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 21s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 20s
2026-09-17 14:16:03 -05:00
fen 8474b8eb02 Cap attachment filenames at 128 chars server-side
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 20s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 20s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 19s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 20s
2026-09-17 10:31:06 -05:00
fen 7ee3ffbfc9 v0.4.0 release prep: README refresh + new wiki screenshots (Ref #160)
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 20s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 26s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 44s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 43s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 44s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 43s
- 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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 44s
2026-09-10 17:05:18 -05:00
fen 785fafcaf9 #233: file attachment title takes the file name, fallback date.ext
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 44s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 42s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 46s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 43s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 45s
- 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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 42s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 43s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 44s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 43s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 46s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 45s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 45s
/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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 42s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 46s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 44s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 50s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 49s
- 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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 39s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 43s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 39s
2026-09-10 12:01:54 -05:00
fen 179eb17da9 Fix #169: persistent full-width divider between dropdown title and body
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 46s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 48s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 47s
# 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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 40s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 43s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 39s
2026-09-10 11:38:22 -05:00
fen f6c7bfa66b Revise notification pill per reopened #168: short text, smaller, side placement
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 39s
- 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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 41s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 41s
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 / docker (pull_request) Skipped
CI / test (pull_request) Successful in 40s
- 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 6064404723 Merge remote fix-168: keep corner pill, cookie-based token flow
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 40s
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 6ef0de6e19 Merge remote-tracking branch 'origin/dev' into qa178
# Conflicts:
#	internal/web/templates/paste.html
2026-09-10 11:23:47 -05:00
fen 3fa2c93e22 clean up notification pill wording, size, and placement (#168)
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 39s
2026-09-10 11:18:57 -05:00
45 changed files with 1314 additions and 1557 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
Palette - self-hosted pastebin with paste cans
Palette - self-hosted pastebin
Copyright (C) 2026 poslop
This program is free software: you can redistribute it and/or modify
+16 -10
View File
@@ -1,7 +1,7 @@
# Palette
Palette is a fast, self-hosted pastebin. One Go binary, a SQLite database, and
a web UI for sharing text and small files
a web UI for sharing text and small files.
> [!NOTE]
> <table><tr><td>
@@ -10,22 +10,27 @@ a web UI for sharing text and small files
## Features
- Multiple files in one paste
- Text pastes
- File attachments (one file per paste, up to 25 MB)
- Password protected pastes
- Expir after a specified time
- Expire after a specified time
- Burn after a number of views
- Custom URLs
- Syntax highlighting with language auto-detection (go-enry)
- Local cookie based submission history
- Cookie based settings
- Themes!
- 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](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmidnight-new.png) | ![Paste view in pastel-peach](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-peach-paste.png) |
| ![History in pastel-lavender](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-lavender-history.png) | ![Saved in pastel-cloud](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-cloud-mine.png) |
| ![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
@@ -61,12 +66,13 @@ go build -o palette ./cmd/palette
| `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max file attachment / multipart 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. |
An `/admin` page exists for runtime settings, protected by a key set at
install (`PALETTE_ADMIN_KEY` env var) and resettable locally — see
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
+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 for multipart request parsing (paste file
# attachments are capped at 25 MB).
# 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,
+256
View File
@@ -0,0 +1,256 @@
# Palette API
Palette's REST API lives under `/api` on the same port that serves the web UI. All request and response bodies are JSON unless noted otherwise. This document describes the API as implemented in `internal/api`.
## Overview
### Base URL
The server listens on the address set by `PALETTE_ADDR` (default `:8080`). In production palette runs behind a reverse proxy, so API URLs are relative to the site root, for example:
```
https://paste.example.com/api/pastes
```
The examples below use `http://localhost:8080` as a placeholder.
### Authentication
There is no user account system. Access control works through three mechanisms:
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
- Deletion token. Creating a paste returns a one-time `deletion_token`. Sending it in the `Authorization` header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
- Admin key. The admin settings endpoints require an install-time key sent in the `X-Admin-Key` header. The key comes from `PALETTE_ADMIN_KEY` or is generated and persisted server-side at first start.
Password-protected pastes are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
### Rate limiting
Rate limits are token buckets keyed by client IP (the rightmost `X-Forwarded-For` entry, then `X-Real-Ip`, then the remote address). Current limits:
| Scope | Limit | Refill |
|---|---|---|
| Paste create | 5 burst (admin tunable) | 60 per minute (admin tunable) |
| Language guess | 5 burst | 1 per second |
| Password unlock | 5 burst, per IP and paste | 5 per minute |
| Admin key check | 5 burst, per IP | 5 per minute |
Exceeding a limit returns `429` with a `Retry-After` header and the error code `rate_limited`. Create and guess responses include `X-RateLimit-Limit` and `X-RateLimit-Burst` headers.
### Size limits
- Paste content: `PALETTE_MAX_TEXT` (default 5 MB), admin tunable. Larger content is rejected with `413`.
- Attached files: 25 MB per file, one file per paste.
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
### Error format
Errors are JSON with a human-readable `error` message and, for create/validation failures, a machine-readable `code`:
```json
{"error": "slug already taken", "code": "slug_taken"}
```
Known codes: `content_empty`, `content_too_large`, `slug_invalid`, `slug_taken`, `slug_reserved`, `expiry_invalid`, `invalid_visibility`, `one_file_only`, `file_too_large`, `rate_limited`, `invalid_request`. Unknown codes should be treated as a generic failure.
## Endpoint reference
### Pastes
#### POST /api/pastes
Create a paste. Rate limited.
Two body formats:
1. `application/json` with these fields:
| Field | Type | Notes |
|---|---|---|
| `content` | string | required; non-whitespace; up to the content cap |
| `title` | string | optional, trimmed, truncated to 200 chars |
| `language` | string | optional, max 40 chars, `^[a-zA-Z0-9+#-]{1,40}$` |
| `custom_slug` | string | optional custom URL slug |
| `password` | string | optional; hash stored server side |
| `expires_in` | string | Go duration (`90m`, `6h`, `168h`); 1 minute to 1 year |
| `burn_after_read` | bool | convenience for a single-read budget |
| `burn_after_reads` | int | number of reads the paste survives (min 1) |
| `visibility` | string | `public` or `unlisted`; default `public` |
| `public` | bool | alias for `visibility`; takes precedence when both are sent |
2. `multipart/form-data` with the same fields as form values plus a `file` part. A paste is either text or one file; if both are sent the file wins and the text is ignored. The MIME type is sniffed from the file content, never from client headers.
If the admin has set a default expiry, pastes created without `expires_in` inherit it.
Response `201`:
```json
{
"id": "ab12cd34",
"deletion_token": "one-time token",
"url": "/ab12cd34",
"raw_url": "/raw/ab12cd34",
"api_url": "/api/pastes/ab12cd34",
"expires_at": 1735689600,
"created_at": 1735603200,
"rate_limit": {"create_per_sec": 1, "burst": 5}
}
```
With a multipart file, an extra `attachment` object is returned: `id`, `filename`, `mime`, `size`, `sha256`, `url` (`/f/{aid}/{filename}`).
The deletion token is also set as a short-lived `tok_{id}` cookie for the creating browser.
Errors: `400` invalid JSON or validation (codes `content_empty`, `slug_invalid`, `slug_taken`, `slug_reserved`, `expiry_invalid`, `one_file_only`), `413` (`content_too_large`, `file_too_large`), `429` rate limited.
#### GET /api/pastes/{id}
Fetch a paste's JSON representation. For password-protected pastes send `X-Paste-Password`; wrong or missing passwords return `401` and count against the unlock rate limit.
Response `200`: `id`, `content`, `content_type`, `language`, `title`, `created_at`, `view_count`, `visibility`, and `reads_remaining` (number or `null` when no read budget is set).
Errors: `404` not found, expired, or burned out; `401` password required; `429` too many unlock attempts.
#### DELETE /api/pastes/{id}
Soft delete a paste. Requires either the deletion token in the `Authorization` header or the creating browser's `vwr` cookie.
Response `200`: `{"status": "soft-deleted"}`.
Errors: `403` deletion token required, `404` not found.
#### DELETE /api/pastes/{id}/redeem
Hard delete immediately using the one-time deletion token in the `Authorization` header. The paste row is removed entirely instead of waiting for the sweeper.
Response `200`: `{"status": "deleted"}`.
Errors: `400` token required, `403` invalid token, `404` not found.
#### GET /api/mine
List pastes created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`.
#### GET /api/public
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `custom_slug`.
### Files and raw content
#### GET /raw/{id}
Paste content served verbatim. Content types outside a safe fixed set (plain text, markdown, JSON, PDF, common images, octet-stream) are served as `text/plain` with `nosniff`. Raw reads count against a burn-after-read budget, with the same per-viewer dedupe window as page views (15 minutes by default, admin tunable). Password-protected pastes are not available raw (`401`).
Errors: `401` password required, `404` not found, expired, or burned.
#### GET /f/{aid}/{filename}
Download a file attachment. The filename path segment is decorative; lookups key on the attachment id. Files are served with the server-sniffed MIME type, `nosniff`, and a safe `Content-Disposition`; only images and PDFs render inline, everything else downloads. Attachments inherit the paste lifecycle: expired or burned pastes make their attachments disappear.
Errors: `404` attachment or paste not found.
### Language detection
#### POST /api/guess-language
Body: `{"content": "package main"}`. Rate limited like create.
Response `200`: `{"language": "go"}`. The result is a best-effort guess and may be `""`.
Errors: `400` invalid JSON body, `429` rate limited.
### Admin
#### GET /admin/api/settings and POST /admin/api/settings
Read or update runtime settings. Requires the `X-Admin-Key` header; wrong keys return `401` and all admin key checks are rate limited to 5 per minute per IP. The admin key is never accepted as a query parameter.
Settings object (POST accepts the full object and persists it):
| Field | Default | Meaning |
|---|---|---|
| `rate_limit_burst` | 5 | paste-create token bucket burst |
| `rate_limit_per_minute` | 60 | paste-create refill rate |
| `max_content_bytes` | 5242880 | paste content cap |
| `default_expiry` | `""` | expiry applied to pastes created without one |
| `custom_slug_reservation_days` | 30 | how long a released slug stays reserved |
| `burn_viewer_window_minutes` | 15 | per-viewer dedupe window for burn reads |
Errors: `400` invalid JSON or validation (all numeric settings must be positive, `default_expiry` must be a duration between 1 minute and 1 year or empty), `401` unauthorized, `429` rate limited.
## Examples
Create a text 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",
"visibility": "public"
}'
```
Response:
```json
{
"id": "ab12cd34",
"deletion_token": "TOKEN",
"url": "/ab12cd34",
"raw_url": "/raw/ab12cd34",
"api_url": "/api/pastes/ab12cd34",
"expires_at": 1735689600,
"created_at": 1735603200,
"rate_limit": {"create_per_sec": 1, "burst": 5}
}
```
Save the `deletion_token`; it is shown once and authorizes deletion.
Create a file paste:
```bash
curl -X POST http://localhost:8080/api/pastes \
-F "title=quarterly report" \
-F "file=@report.pdf"
```
Fetch a paste:
```bash
curl http://localhost:8080/api/pastes/ab12cd34
# password-protected paste (header only; query parameters are not accepted):
curl -H "X-Paste-Password: secret" http://localhost:8080/api/pastes/ab12cd34
```
Fetch raw content:
```bash
curl http://localhost:8080/raw/ab12cd34
```
Soft delete (paste becomes unavailable but is recoverable during the grace period):
```bash
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34
```
Hard delete immediately (row removed):
```bash
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34/redeem
```
## Deletion and expiry behavior
- Expired pastes are soft-deleted by a background sweeper that runs every minute.
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- The deletion token allows immediate hard delete via the redeem endpoint.
- Burn-after-read pastes are soft-deleted once the read budget is exhausted. A read is counted once per unique viewer within the dedupe window (15 minutes by default); the same viewer returning inside the window does not consume another read.
+61
View File
@@ -306,3 +306,64 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) {
t.Fatalf("paste should require password, got %d", rec2.Code)
}
}
// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes,
// not just raster images (the old isImageMime gate left non-image
// attachments serving an empty body from row.Content).
func TestRawStreamsNonImageAttachment(t *testing.T) {
s := testServer(t)
h := s.routes()
body := []byte("hello, this is a plain text attachment body")
rec, resp := multipartCreate(t, h, "notes.txt", body, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
if resp["attachment"] == nil {
t.Fatalf("no attachment in response: %v", resp)
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("nosniff = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), body) {
t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body))
}
}
// #281: active-content attachment types still get forced to text/plain on
// /raw, same rule as the /f/ serving path (#34).
func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) {
s := testServer(t)
h := s.routes()
html := []byte("<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")
}
}
-273
View File
@@ -1,273 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"palette/internal/store"
"strings"
"time"
"github.com/go-chi/chi/v5"
"palette/internal/web"
)
// CreateCan makes a can with N items (multipart form).
// Fields: title, description, visibility, expires_in, password, files (one or more), or json_items for text items.
func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(a.cfg.MaxItemBytes); err != nil {
writeErr(w, 400, "multipart form required")
return
}
title := r.FormValue("title")
visibility := r.FormValue("visibility")
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "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 {
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
return
}
// #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) {
writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year")
return
}
t := now + int64(d.Seconds())
expiresAt = &t
}
var pwHash *string
if password != "" {
h, err := store.Argon2IDHash(password)
if err != nil {
writeErr(w, 500, "hash error")
return
}
pwHash = &h
}
canID := store.GenSlug(8)
var slugPtr *string
if customSlug != "" {
slugPtr = &customSlug
}
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
if err != nil {
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
if itemsJSON := r.FormValue("json_items"); itemsJSON != "" {
var items []map[string]string
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
writeErr(w, 400, "invalid json_items")
return
}
for _, it := range items {
content := it["content"]
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("item %q exceeds max", it["title"]))
return
}
lang := it["language"]
if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
// uploaded files
if r.MultipartForm != nil {
for _, headers := range r.MultipartForm.File {
for _, fh := range headers {
f, err := fh.Open()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
content, err := io.ReadAll(f)
f.Close()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("file %q exceeds max %d bytes", fh.Filename, a.cfg.MaxItemBytes))
return
}
contentStr := string(content)
if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
}
if itemCount == 0 {
a.store.DeleteCan(canID)
writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)")
return
}
writeJSON(w, 201, map[string]any{
"id": canID, "url": "/can/" + canID, "items": itemCount,
})
}
// 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 {
case strings.HasSuffix(lower, ".png"):
return "image/png"
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lower, ".gif"):
return "image/gif"
case strings.HasSuffix(lower, ".webp"):
return "image/webp"
case strings.HasSuffix(lower, ".pdf"):
return "application/pdf"
}
if len(content) > 8 && content[0] == 0x89 && content[1] == 'P' {
return "image/png"
}
return "text/plain"
}
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil {
writeErr(w, 500, "db error")
return
}
if can == nil {
writeErr(w, 404, "can not found")
return
}
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
writeErr(w, 404, "can expired")
return
}
if can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
items, err := a.store.ListCanItems(can.ID)
if err != nil {
writeErr(w, 500, "db error")
return
}
type itemMeta struct {
ID string `json:"id"`
Title *string `json:"title"`
ContentType string `json:"content_type"`
Size int `json:"size"`
URL string `json:"url"`
}
metas := make([]itemMeta, 0, len(items))
for _, it := range items {
metas = append(metas, itemMeta{
ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
})
}
writeJSON(w, 200, map[string]any{
"id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
"created_at": can.CreatedAt, "items": metas,
})
}
func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "item")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "item not found")
return
}
// must belong to a can
if !row.CanID.Valid {
writeErr(w, 404, "not a can item")
return
}
// 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 == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
// 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.
ct := row.ContentType
if !safeRawContentType(ct) {
ct = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write([]byte(row.Content))
}
-45
View File
@@ -1,45 +0,0 @@
package api
import (
"net/http/httptest"
"testing"
)
// #60: the cans API must clamp expires_in at the boundary exactly like the
// pastes API — reject zero/negative durations and anything over the 1-year
// UI cap, accept the exact boundaries.
func TestCreateCanExpiryBounds(t *testing.T) {
s := testServer(t)
h := s.routes()
cases := []struct {
expiresIn string
wantCode int
}{
{"-1h", 400}, // negative
{"-0s", 400}, // negative zero
{"0s", 400}, // zero
{"1ns", 400}, // positive but below the 1-minute floor
{"59s", 400}, // just under the floor
{"1m", 201}, // exactly the floor
{"90s", 201}, // just over the floor
{"8760h", 201}, // exactly 1 year
{"8785h", 400}, // 1 year + 1 day: over the cap
{"87600h", 400}, // 10 years, the originally reported case
}
for _, c := range cases {
globalLimiter = newLimiter() // avoid create rate limit between cases
body, ct := multipartBody(t, map[string]string{
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
"expires_in": c.expiresIn,
}, "files", "pic.txt", "file data")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.wantCode {
t.Errorf("expires_in %q: got %d want %d (%s)",
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
}
}
}
-414
View File
@@ -1,414 +0,0 @@
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
}
-144
View File
@@ -1,144 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
)
func multipartBody(t *testing.T, fields map[string]string, fileField, fileName, fileContent string) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
w.WriteField(k, v)
}
if fileField != "" {
fw, _ := w.CreateFormFile(fileField, fileName)
fw.Write([]byte(fileContent))
}
w.Close()
return &buf, w.FormDataContentType()
}
func TestCreateAndGetCan(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "My can",
"json_items": `[{"title":"a.txt","content":"AAA"},{"title":"b.txt","content":"BBB"}]`,
"expires_in": "1h",
}, "files", "pic.txt", "file data")
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: got %d: %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
Items int `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.Items != 3 {
t.Fatalf("expected 3 items, got %d", created.Items)
}
// get can
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("get can: got %d", rec.Code)
}
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
if len(can.Items) != 3 {
t.Fatalf("expected 3 items in get, got %d", len(can.Items))
}
// fetch item
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+can.Items[0].ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item fetch: got %d", rec.Code)
}
}
func TestEmptyCanRejected(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{"title": "empty"}, "", "", "")
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 empty can, got %d", rec.Code)
}
}
func TestCanPasswordInheritedByItems(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "locked",
"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)
// can without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("expected 401, got %d", rec.Code)
}
// get item id with pw
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
// item without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("item expected 401, got %d", rec.Code)
}
// item with pw -> 200
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 {
t.Fatalf("item expected 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "sec") {
t.Fatalf("item content mismatch: %s", rec.Body.String())
}
}
+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
}
+15 -4
View File
@@ -1,7 +1,7 @@
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.
// #143: create responses must set the short-lived tok_<id> HttpOnly+Secure
// cookie that the paste view reads for the one-time created banner.
import (
"net/http"
@@ -29,6 +29,9 @@ func TestCreateSetsDeletionTokenCookie(t *testing.T) {
if !c.HttpOnly {
t.Error("tok_ cookie not HttpOnly")
}
if !c.Secure {
t.Error("tok_ cookie not Secure")
}
if c.MaxAge != 60 {
t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge)
}
@@ -91,7 +94,15 @@ func TestCreatedBannerViaCookie(t *testing.T) {
if rec2.Code != 200 {
t.Fatalf("paste view: got %d", rec2.Code)
}
if !strings.Contains(rec2.Body.String(), tok) {
t.Error("created banner does not show the deletion token (#143 cookie flow broken)")
// #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)")
}
}
-35
View File
@@ -86,38 +86,3 @@ func TestErrorCodeRateLimited(t *testing.T) {
}
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)
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func newTestServer138(t *testing.T) *httptest.ResponseRecorder {
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
req := httptest.NewRequest("GET", "/history", nil)
req := httptest.NewRequest("GET", "/public", nil)
rec := httptest.NewRecorder()
a.routes().ServeHTTP(rec, req)
return rec
+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 -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 {
-30
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.
+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])
}
}
}
+73 -146
View File
@@ -1,5 +1,5 @@
// Package api implements palette's REST handlers and the HTTP router:
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
// pastes, guess-language, rate limiting middleware, and the admin API.
package api
import (
@@ -8,7 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
@@ -18,7 +17,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
langpkg "palette/internal/lang"
"palette/internal/store"
"palette/internal/web"
)
@@ -59,8 +57,7 @@ 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).
// createErrCode maps a store.CreatePaste error to its UI error code (#105).
func createErrCode(err error) string {
switch {
case errors.Is(err, store.ErrSlugTaken):
@@ -103,26 +100,21 @@ func (a *apiServer) routes() http.Handler {
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic)
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 (#4): GET renders, POST unlocks (same flow as pastes)
r.Get("/can/{id}", a.handleCanPage)
r.Post("/can/{id}", a.handleCanPage)
// 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)
@@ -186,7 +178,7 @@ func viewerSentCookie(r *http.Request) bool {
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,
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
})
}
@@ -315,11 +307,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)
@@ -376,6 +383,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)
@@ -394,10 +428,9 @@ 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})
@@ -415,10 +448,9 @@ 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,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -451,6 +483,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 —
@@ -484,130 +535,6 @@ func safeRawContentType(ct string) bool {
return false
}
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
http.NotFound(w, r)
return
}
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 {
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)
}
}
func templateEsc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
h := a.webHandlers()
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
)
// #68 input-validation helpers. Paste/can payloads are size-capped and list
// #68 input-validation helpers. Paste payloads are size-capped and list
// endpoints get a single place where limit/offset are parsed and clamped.
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
+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")
}
+4 -1
View File
@@ -26,7 +26,10 @@ type Attachment struct {
SizeHuman string `json:"-"` // template-only: human-readable size
}
const MaxFilenameLen = 255
// MaxFilenameLen caps stored attachment filenames (bytes) to bound DB
// rows and Content-Disposition echoes. 128 keeps names readable while
// stopping filename-bloat abuse; longer names truncate.
const MaxFilenameLen = 128
// ErrFileTooLarge is returned when an attachment exceeds the per-file cap.
var ErrFileTooLarge = errors.New("file too large")
+5
View File
@@ -105,4 +105,9 @@ func TestSanitizeFilename(t *testing.T) {
if got := SanitizeFilename(long); len(got) != MaxFilenameLen {
t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen)
}
// issue #248: a 250-char multipart filename must truncate to the cap
repro := strings.Repeat("b", 246) + ".txt"
if got := SanitizeFilename(repro); len(got) != MaxFilenameLen {
t.Errorf("repro name len = %d want %d", len(got), MaxFilenameLen)
}
}
+2 -8
View File
@@ -10,9 +10,10 @@ var slugRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]{0,63}$`)
// reserved words that would collide with routes
var reservedSlugs = map[string]bool{
"api": true, "raw": true, "can": true, "cans": true, "public": true,
"api": true, "raw": 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")
@@ -35,12 +36,5 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
if err != nil {
return false, err
}
if n > 0 {
return true, nil
}
err = s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans WHERE id = ?`, slug).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil
}
+108
View File
@@ -0,0 +1,108 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
)
// Cans feature removal (2026-10-16): opening a store on a pre-removal
// database must drop the paste_cans table (only when empty) and the
// pastes.can_id column. A non-empty paste_cans table is left in place.
func TestMigrateDropsCansSchema(t *testing.T) {
dir := t.TempDir()
legacy := func(name string, canRows int) string {
db, err := sql.Open("sqlite", filepath.Join(dir, name))
if err != nil {
t.Fatalf("open raw db: %v", err)
}
defer db.Close()
stmts := []string{
`CREATE TABLE pastes (
id TEXT PRIMARY KEY, custom_slug TEXT UNIQUE, content TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'text/plain', language TEXT, title TEXT,
password_hash TEXT, expires_at INTEGER, burn_after_read INTEGER DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'public', can_id TEXT,
created_at INTEGER NOT NULL, deleted_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0, deletion_token TEXT
)`,
`CREATE TABLE paste_cans (
id TEXT PRIMARY KEY, title TEXT, description TEXT,
visibility TEXT NOT NULL DEFAULT 'public', password_hash TEXT,
created_at INTEGER NOT NULL, deleted_at INTEGER, expires_at INTEGER
)`,
`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`,
`INSERT INTO pastes (id, content, content_type, visibility, can_id, created_at)
VALUES ('p1', 'x', 'text/plain', 'public', NULL, 1)`,
}
for _, q := range stmts {
if _, err := db.Exec(q); err != nil {
t.Fatalf("seed %q: %v", q, err)
}
}
for i := 0; i < canRows; i++ {
if _, err := db.Exec(`INSERT INTO paste_cans (id, created_at) VALUES (?, 1)`, string(rune('a'+i))); err != nil {
t.Fatalf("seed can row: %v", err)
}
}
return filepath.Join(dir, name)
}
intCol := func(s *Store, table, col string) int {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, col).Scan(&n)
return n
}
tableExists := func(s *Store, table string) bool {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n)
return n > 0
}
// Empty paste_cans: table and column both dropped.
s1, err := OpenStore(legacy("empty.db", 0))
if err != nil {
t.Fatalf("open: %v", err)
}
defer s1.db.Close()
if tableExists(s1, "paste_cans") {
t.Error("paste_cans still exists after migrate (was empty; should be dropped)")
}
if intCol(s1, "pastes", "can_id") != 0 {
t.Error("pastes.can_id still exists after migrate; should be dropped")
}
// The store still works end to end.
if _, err := s1.CreatePaste(&Paste{Content: "hello", Visibility: "public"}); err != nil {
t.Fatalf("create paste on migrated db: %v", err)
}
if got := s1.QueryInt(`SELECT COUNT(*) FROM pastes`); got != 2 {
t.Errorf("pastes count = %d, want 2", got)
}
// Non-empty paste_cans: table kept, can_id still dropped.
s2, err := OpenStore(legacy("full.db", 2))
if err != nil {
t.Fatalf("open: %v", err)
}
defer s2.db.Close()
if !tableExists(s2, "paste_cans") {
t.Error("non-empty paste_cans was dropped; must be kept until cleaned")
}
if intCol(s2, "pastes", "can_id") != 0 {
t.Error("pastes.can_id still exists after migrate; should be dropped")
}
// Fresh database: neither the table nor the column is ever created.
s3, err := OpenStore(filepath.Join(dir, "fresh.db"))
if err != nil {
t.Fatalf("open fresh: %v", err)
}
defer s3.db.Close()
if tableExists(s3, "paste_cans") {
t.Error("fresh db created paste_cans")
}
if intCol(s3, "pastes", "can_id") != 0 {
t.Error("fresh db created pastes.can_id")
}
}
+43 -148
View File
@@ -32,7 +32,6 @@ type Paste struct {
Visibility string `json:"visibility"`
// #83: accept "public": true/false as an alias for visibility.
Public *bool `json:"public,omitempty"`
CanID *string `json:"can_id,omitempty"`
CreatedAt int64 `json:"created_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
@@ -55,26 +54,13 @@ type PasteRow struct {
ReadsLimit sql.NullInt64
ReadsUsed int
Visibility string
CanID sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ViewCount int
Size int
DeletionToken sql.NullString
ViewerID sql.NullString
IsCan bool // set on list rows that are cans (#4)
}
type CanRow struct {
ID string
Title sql.NullString
Visibility string
PasswordHash sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64
Description sql.NullString
ViewerID sql.NullString
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
}
type Store struct {
@@ -118,7 +104,6 @@ func (s *Store) migrate() error {
expires_at INTEGER,
burn_after_read INTEGER DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'public',
can_id TEXT,
created_at INTEGER NOT NULL,
deleted_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0,
@@ -127,22 +112,32 @@ deletion_token TEXT
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS paste_cans (
id TEXT PRIMARY KEY,
title TEXT,
description TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
password_hash TEXT,
created_at INTEGER NOT NULL,
deleted_at INTEGER,
expires_at INTEGER
);
`)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
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)
// Cans feature removal (owner decision 2026-10-16): drop the leftover
// schema. paste_cans is only dropped when empty so no data is silently
// destroyed; a non-empty table is left in place and logged. The dormant
// pastes.can_id column is dropped too (prod verified clean 2026-10-16:
// zero rows referenced a can).
var tbl int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='paste_cans'`).Scan(&tbl)
if tbl > 0 {
var rows int
s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans`).Scan(&rows)
if rows == 0 {
s.db.Exec(`DROP TABLE paste_cans`)
} else {
log.Printf("store: paste_cans still holds %d row(s); leaving the table in place (cans feature removed)", rows)
}
}
var col int
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pastes') WHERE name='can_id'`).Scan(&col)
if col > 0 {
s.db.Exec(`ALTER TABLE pastes DROP COLUMN can_id`)
}
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL,
@@ -289,10 +284,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
}
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
var r PasteRow
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -300,18 +295,15 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
}
// ListPublic backs /api/public and the public listing page. Visibility rules
// mirror the history page: only non-deleted, non-expired, non-can pastes are
// listed, and password-protected pastes are excluded at the query level
// (#65) so their metadata (title, slug, existence) never leaks.
// mirror the history page: only non-deleted, non-expired pastes are 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), 0
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 > ?)
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)
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), 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.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -320,33 +312,27 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
var isCan int
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil {
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &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 (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)
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
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), 0
FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR 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)
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), 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.expires_at IS NULL OR p.expires_at > ?)
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -355,18 +341,15 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
var isCan int
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil {
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &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 (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)
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
viewerID, time.Now().Unix()).Scan(&total)
return out, total, nil
}
@@ -429,8 +412,6 @@ func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool
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)
}
@@ -487,92 +468,6 @@ func (s *Store) HardDelete(id string) {
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
}
// 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)
}
// InsertCanItem adds an item paste belonging to a can.
func (s *Store) InsertCanItem(canID, title, content, contentType string, language *string, expiresAt, binary *string, now int64) error {
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
_, err := s.db.Exec(`INSERT INTO pastes
(id, content, content_type, language, title, visibility, can_id, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
_ = expiresAt
_ = binary
return err
}
func (s *Store) GetCan(id string) (*CanRow, error) {
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, &c.Description, &c.ViewerID)
if err == sql.ErrNoRows {
return nil, nil
}
return &c, err
}
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PasteRow
for rows.Next() {
var r PasteRow
if err := rows.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// GenSlug is the exported slug generator.
func GenSlug(n int) string { return genSlug(n) }
+163 -45
View File
@@ -197,11 +197,17 @@ 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; }
@@ -210,7 +216,10 @@ body {
.tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; }
.paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; word-break: normal; overflow-wrap: anywhere; }
.stats-pill { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
/* #222: .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; }
@@ -249,6 +258,7 @@ body {
.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; }
@@ -273,6 +283,10 @@ body {
.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; }
@@ -285,14 +299,33 @@ html[data-wrap] .float { overflow-x: hidden; }
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.code {
font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh);
padding: 14px 0; display: flex; overflow-x: auto;
padding: 14px 0; display: flex; overflow-x: hidden;
}
/* #261: horizontal scroll must live on the codebody, not the .code flex
container — a container-level scroll takes the gutter with it when the
user scrolls long lines. The gutter sits OUTSIDE the scroll container and
stays visible; the codebody shrinks to the remaining space and scrolls
(min-width: 0 lets it shrink below its content width inside the flex row). */
.code .codebody { flex: 1 1 auto; min-width: 0; overflow-x: auto; }
/* #167: the gutter must not drive the flex layout — its content width
(row count × number width) shrinks the code column, which re-wraps lines,
which grows the gutter: a feedback loop. Pin the gutter with a fixed
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; }
@@ -321,10 +354,15 @@ 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; }
@@ -388,15 +426,15 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
}
.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); }
@@ -410,13 +448,15 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
/* protection section rhythm (#20) */
.protect { display: flex; flex-direction: column; gap: 2px; }
.protect .pw-row { padding: 2px 8px 4px; }
.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 {
margin: 6px 0 4px 8px;
padding: 8px 10px;
/* #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);
@@ -493,7 +533,7 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
/* 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 a.slug.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }
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); }
@@ -501,12 +541,11 @@ td .id-link:hover { color: var(--accent); }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
th.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
/* #101: arrow sits LEFT of the label, further spaced, vertically centered,
and absolutely positioned so it never shifts the label text. */
th.sortable { position: relative; padding-left: 24px; }
/* #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 {
position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
display: inline-block; width: 0; height: 0;
/* #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); }
@@ -554,6 +593,20 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
border-color: var(--ok);
}
/* #260: static-width success feedback. Label and checkmark stack in one
grid cell, so the button is always as wide as the wider of the two and
never shifts on click. Feedback is a pure .ok class toggle. */
.swapbtn {
display: inline-grid;
}
.swapbtn > * {
grid-area: 1 / 1;
justify-self: center;
}
.swapbtn .swap-check { visibility: hidden; }
.swapbtn.ok .swap-check { visibility: visible; }
.swapbtn.ok .swap-label { visibility: hidden; }
/* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em;
@@ -623,9 +676,12 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
.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 */
@@ -638,19 +694,6 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
.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); }
.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
@@ -778,23 +821,66 @@ button[type="submit"]:focus-visible,
font-size: 22px; padding: 0 4px; border-radius: var(--radius-sm);
}
.file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); }
.attachment-bar { display: flex; flex-direction: column; gap: 10px; }
.attachment-chip {
.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: 8px 16px; text-decoration: none; color: var(--fg);
background: var(--surface-2); font-size: 21.6px;
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;
}
.attachment-chip:hover { border-color: var(--accent); }
.attachment-chip .attachment-size { color: var(--muted-fg); font-size: 19px; }
.attachment-preview img { max-width: 480px; max-height: 360px; border-radius: var(--radius); border: 1px solid var(--border); }
/* #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: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; }
.col-f { width: 100px; } .col-g { width: 190px; }
.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; }
@@ -804,4 +890,36 @@ button[type="submit"]:focus-visible,
.mt18 { margin-top: 18px; }
.toggle-inline { display: inline-flex; }
.wrap-normal { word-break: normal; overflow-wrap: break-word; }
.created-banner.show { display: block; }
.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

+4 -4
View File
@@ -6,12 +6,12 @@ const t = PaletteTable.init({
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}${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>' : ''}`) +
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</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><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</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.',
+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);
}
})();
+4 -4
View File
@@ -18,12 +18,12 @@ const t = PaletteTable.init({
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}${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>' : ''}`) +
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</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><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</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.',
+93 -23
View File
@@ -2,13 +2,73 @@
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
// #274: with wrap on, a logical line occupies several VISUAL rows in the
// textarea, so one number per logical line drifts off its text (same bug the
// paste view fixed in #167). A textarea can't be split into spans, so the
// wrapped row count per logical line is measured with a hidden mirror div
// that shares the editor's font, line metrics and wrapping rules, and the
// gutter renders one .gutline block per visual row with the number on the
// FIRST row of its logical line (fillers elsewhere).
let mirror = null;
function measureRows(lines) {
if (!mirror) {
mirror = document.createElement('div');
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.top = '0';
mirror.style.left = '-9999px';
document.body.appendChild(mirror);
}
const cs = getComputedStyle(content);
mirror.style.font = cs.font;
mirror.style.lineHeight = cs.lineHeight;
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.overflowWrap = 'anywhere';
mirror.style.wordBreak = 'break-all';
mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px';
const lh = parseFloat(cs.lineHeight) || 1;
const starts = [];
let total = 0;
const n = Math.max(lines.length, 1);
for (let i = 0; i < n; i++) {
// A trailing newline yields an empty last line: it still occupies one row.
mirror.textContent = lines[i] + '\n';
let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh));
starts.push(total);
total += rows;
}
return { starts, total };
}
function updateGutter() {
const lines = content.value.split('\n').length;
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 <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = 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) {
@@ -190,15 +250,16 @@ async function create() {
// button, password auto-unlock, then redirect to the paste.
function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
// #260 attempt 2: .swapbtn markup — label and checkmark share one grid
// cell, so the button width is static and feedback is a class toggle.
showResult('<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)
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
setTimeout(() => copyBtn.classList.remove('ok'), 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// token carried via sessionStorage, never in the URL (#143)
@@ -254,16 +315,25 @@ function extOf(name) {
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)) {
if (previewURL) URL.revokeObjectURL(previewURL);
previewURL = URL.createObjectURL(file);
img.src = previewURL;
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');
@@ -271,11 +341,12 @@ async function showFileInEditor(file) {
if (!TEXT_EXTS.has(extOf(file.name))) return; // unknown binary: leave editor alone
try {
const text = await file.text();
if (!content.value.trim()) {
// #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) {}
}
@@ -297,20 +368,19 @@ function setAttachedFile(file) {
attachedFile = file;
renderFileChip();
setHidden('file-text-note', false);
// #171: title auto-fill — images take the file name; text files use the
// language-placeholder convention (e.g. Python.py, fallback Text.txt).
// Only when the title is still blank; never overwrite a typed title.
// #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()) {
if (IMAGE_RE.test(file.type)) {
$('title').value = file.name;
const raw = (file.name || '').trim();
if (raw) {
$('title').value = raw;
} else {
const lang = LANG_BY_EXT[extOf(file.name)];
if (lang) {
const fn = defaultFilename(lang);
if (fn) $('title').value = fn;
} else if (TEXT_EXTS.has(extOf(file.name))) {
$('title').value = defaultFilename('text') || 'Text.txt';
}
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);
+87 -28
View File
@@ -1,5 +1,6 @@
// #167: with line wrapping on, a logical line can occupy several visual
// lines; the gutter must show one number per VISUAL line. Per-line spans
// 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 () {
@@ -13,7 +14,13 @@
};
// Wrap each logical line (split on newline; spans never contain newlines
// because HighlightCode highlights per line) in a .codeline block.
// 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');
@@ -21,40 +28,92 @@
for (var i = 0; i < parts.length; i++) {
out.push('<span class="codeline">' + parts[i] + '</span>');
}
body.innerHTML = out.join('\n');
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 = '';
if (wrapOn() && lines.length) {
// Each logical line block occupies rows = height / line-height when
// wrapped; its number sits on the first row and the remaining rows get
// blank gutter lines so numbers stay aligned with line starts.
// The code column width must not change while measuring (the gutter is
// flex-shrink:0 so its own row count never affects it), and renumber
// must be idempotent to avoid a ResizeObserver feedback loop.
var lh = parseFloat(getComputedStyle(body).lineHeight) || 1;
gutter.textContent = '';
var frag = document.createDocumentFragment();
for (var i = 0; i < lines.length; i++) {
var num = document.createElement('span');
num.className = 'gutline';
num.textContent = String(i + 1);
frag.appendChild(num);
var rows = Math.max(1, Math.round(lines[i].getBoundingClientRect().height / lh));
for (var r = 1; r < rows; r++) {
var blank = document.createElement('span');
blank.className = 'gutline';
blank.textContent = '\u00a0';
frag.appendChild(blank);
}
}
gutter.appendChild(frag);
} else {
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();
+27 -9
View File
@@ -16,17 +16,26 @@ function toggleStats() {
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) {
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');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
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;
@@ -34,14 +43,23 @@ function redeem() {
try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {}
if (!tok) { alert('deletion token not available in this browser'); return; }
fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
.then(r => { if (r.ok) location.href = '/public'; else alert('delete failed'); });
}
// wiring (moved from inline handlers for CSP #139)
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);
+9 -2
View File
@@ -73,7 +73,10 @@
});
btn.addEventListener('click', function () {
var dark = state().dark;
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
// #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');
@@ -91,11 +94,15 @@
if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false');
}
sync();
// the topbar script runs before this button exists, so wire it here
// 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);
})();
+3 -3
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;
};
@@ -104,7 +104,7 @@ const PaletteTable = (() => {
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, filtPages]);
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)) {
@@ -121,7 +121,7 @@ const PaletteTable = (() => {
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)) {
+3
View File
@@ -31,6 +31,9 @@
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;
-36
View File
@@ -1,36 +0,0 @@
{{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" .}}
+8 -8
View File
@@ -8,15 +8,15 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d"><col class="col-e"><col class="col-f"><col class="col-g"></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>
+4 -3
View File
@@ -2,16 +2,17 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
<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>/ {{ version }}</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>
+8 -7
View File
@@ -8,14 +8,15 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-e"><col class="col-f"></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>
+6 -2
View File
@@ -19,7 +19,7 @@
<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>
<button type="button" class="btn btn-icon wrap-toggle" id="wrap-toggle" title="Toggle line wrap" aria-pressed="false">Wrap</button>
</div>
</div>
</div>
@@ -28,12 +28,15 @@
<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 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">
@@ -91,4 +94,5 @@
</div>
</div>
<script src="/static/new.js" defer></script>
<script src="/static/jump.js" defer></script>
{{template "foot" .}}
+17 -14
View File
@@ -6,10 +6,11 @@
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
<div class="spacer"></div>
<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" href="#" id="copy-btn">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">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">
@@ -20,7 +21,7 @@
</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>
@@ -34,31 +35,33 @@
</div>
</div>
{{if .JustCreated}}
<div class="float">
<div class="created-banner show">
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
</div>
</div>
<div class="created-pill" id="created-pill" role="status">Paste Created</div>
{{end}}
{{if .Attachment}}
<div class="float">
<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>
{{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}}
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
{{end}}
</div>
</div>
{{end}}
{{if not .Attachment}}
<div class="float">
<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 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" .}}
+64 -37
View File
@@ -1,5 +1,5 @@
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
// the admin page. Templates and static assets are embedded in this package.
// Package web serves palette's HTML routes: paste pages, unlock, and the
// admin page. Templates and static assets are embedded in this package.
package web
import (
@@ -111,29 +111,6 @@ 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
@@ -231,6 +208,20 @@ func (h *Handlers) renderPageStatus(w http.ResponseWriter, name string, status i
}
}
// 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 := ""
@@ -245,27 +236,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,
@@ -279,6 +305,7 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC
"ReadsTotal": int(row.ReadsLimit.Int64),
"JustCreated": justCreated,
"Attachment": attachment,
"AttachmentImage": attImage,
"Host": "this host",
}
h.renderPage(w, "paste.html", data)
@@ -319,7 +346,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
// the paste id under the server's random secret.
http.SetCookie(w, &http.Cookie{
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
})
// re-render without lock, or redirect if ?next= was given (#26)
if next := r.FormValue("next"); next != "" {
@@ -352,8 +379,8 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
token = c.Value
}
if justCreated && token != "" {
// one-time display of the deletion token via the created banner
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
// 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, Secure: true})
}
// 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
@@ -383,9 +410,9 @@ func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
}
// HandleHistoryPage serves /history.
// HandleHistoryPage serves /public.
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
h.renderPage(w, "history.html", map[string]any{"Page": "public"})
}
// HandleSettingsPage serves /settings.
@@ -402,9 +429,9 @@ func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes})
}
// HandleMinePage serves /mine.
// HandleMinePage serves /saved.
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
h.renderPage(w, "mine.html", map[string]any{"Page": "saved"})
}
// HandleAdminPage serves /admin.
@@ -416,7 +443,7 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
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
// response is text/html (page templates); JSON API
// 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