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

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

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

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

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

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

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

Ref #222
2026-09-10 14:16:28 -05:00
30 changed files with 720 additions and 210 deletions
+16 -10
View File
@@ -1,7 +1,7 @@
# Palette # Palette
Palette is a fast, self-hosted pastebin. One Go binary, a SQLite database, and 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] > [!NOTE]
> <table><tr><td> > <table><tr><td>
@@ -10,22 +10,27 @@ a web UI for sharing text and small files
## Features ## Features
- Multiple files in one paste - Text pastes and cans (multiple items in one share)
- File attachments (one file per paste, up to 25 MB)
- Password protected pastes - Password protected pastes
- Expir after a specified time - Expire after a specified time
- Burn after a number of views - Burn after a number of views
- Custom URLs - Custom URLs
- Syntax highlighting with language auto-detection (go-enry) - Syntax highlighting with language auto-detection (go-enry)
- Local cookie based submission history - Public paste listing with search, sort and pagination
- Cookie based settings - Cookie based saved pastes and settings
- Themes! - 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 ## 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) | | ![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) |
| ![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) | | ![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 ## Get Started
@@ -61,12 +66,13 @@ go build -o palette ./cmd/palette
| `PALETTE_ADDR` | `:8080` | Listen address | | `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) | | `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) | | `PALETTE_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_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 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](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 ## API
+7 -1
View File
@@ -32,10 +32,16 @@ services:
# Default: 5242880 (5 MiB). # Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880" # PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can). # Max size in bytes of a single can item (file/text inside a can) or a
# paste file attachment.
# Default: 26214400 (25 MiB). # Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400" # 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, # HMAC secret for password-unlock cookies. Default: random per start,
# which logs out every unlocked browser session on restart. Set a fixed # which logs out every unlocked browser session on restart. Set a fixed
# secret (any random string) to keep unlock sessions across restarts, # secret (any random string) to keep unlock sessions across restarts,
+61
View File
@@ -306,3 +306,64 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) {
t.Fatalf("paste should require password, got %d", rec2.Code) 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")
}
}
+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
}
+1 -1
View File
@@ -29,7 +29,7 @@ func newTestServer138(t *testing.T) *httptest.ResponseRecorder {
globalSettingsFn = ss.get globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil }) t.Cleanup(func() { globalSettingsFn = nil })
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} 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() rec := httptest.NewRecorder()
a.routes().ServeHTTP(rec, req) a.routes().ServeHTTP(rec, req)
return rec 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"} a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
h := a.routes() h := a.routes()
alice := viewerCookieFor(t, h, "/history") alice := viewerCookieFor(t, h, "/public")
if alice == "" { if alice == "" {
t.Fatal("no viewer cookie issued") t.Fatal("no viewer cookie issued")
} }
@@ -89,7 +89,7 @@ func TestMineCreateListDelete(t *testing.T) {
} }
// a different browser's cookie does NOT see it // 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, "") rec = doReq(t, h, "GET", "/api/mine", bob, "")
json.Unmarshal(rec.Body.Bytes(), &list) json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 { if list.Total != 0 {
-30
View File
@@ -3,7 +3,6 @@ package api
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -48,35 +47,6 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
return true 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() var globalLimiter = newLimiter()
// globalSettingsFn is set at startup; tests can point it at fixed settings. // globalSettingsFn is set at startup; tests can point it at fixed settings.
+31 -66
View File
@@ -1,85 +1,50 @@
package api 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 ( import (
"bytes" "fmt"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
) )
func TestClientIPTakesRightmostXFF(t *testing.T) { func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod 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") 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") r.Header.Set("X-Real-Ip", "203.0.113.10")
if got := clientIP(r); got != "203.0.113.10" { if got := clientIP(r); got != "203.0.113.7" {
t.Fatalf("clientIP = %q, want 203.0.113.10", got) t.Fatalf("clientIP = %q, want peer 203.0.113.7", got)
} }
} }
func TestClientIPDirectFallback(t *testing.T) { // A proxy-controlled header is not honored even when set: #280 revision
r := httptest.NewRequest("POST", "/", nil) // removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the
r.RemoteAddr = "198.51.100.5:51000" // bucket key is the peer address only.
if got := clientIP(r); got != "198.51.100.5" { func TestClientIPNeverTrustsHeaders(t *testing.T) {
t.Fatalf("clientIP = %q, want 198.51.100.5", got) 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 // Issue #280: rotating X-Forwarded-For must NOT reset the bucket. Pentest
// leftmost XFF entry stays limited on their real (rightmost) IP. // repro was 8 creates with rotating XFF -> 6x201.
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) { func TestRotatingXFFDoesNotResetBucket(t *testing.T) {
srv := newTestServer(t) globalLimiter = newLimiter()
h := srv.routes() s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway
for i := 0; i < 5; i++ { var allowed, limited int
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) for i := 0; i < 8; i++ {
req.RemoteAddr = "10.42.0.7:51000" r := httptest.NewRequest("POST", "/api/pastes", nil)
// each request spoofs a DIFFERENT leftmost entry r.RemoteAddr = "198.51.100.1:5000"
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9") r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i))
rr := httptest.NewRecorder() if rateLimitCreate(r, s) {
h.ServeHTTP(rr, req) allowed++
if rr.Code != 201 { } else {
t.Fatalf("req %d: want 201, got %d", i, rr.Code) limited++
} }
} }
// 6th request, still the same real IP, new spoofed prefix: must 429 if float64(allowed) != s.RateLimitBurst || limited != 8-int(s.RateLimitBurst) {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) t.Fatalf("rotating XFF: allowed=%d limited=%d, want allowed=%v (burst), limited=%d", allowed, limited, s.RateLimitBurst, 8-int(s.RateLimitBurst))
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)
}
} }
} }
+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])
}
}
}
+69 -5
View File
@@ -118,11 +118,14 @@ func (a *apiServer) routes() http.Handler {
r.Get("/raw/{id}", a.handleRaw) r.Get("/raw/{id}", a.handleRaw)
// web pages // 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("/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("/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.Handle("/static/*", a.ui.StaticHandler())
r.Get("/unlock/{id}", a.handlePasteView) r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView) r.Post("/unlock/{id}", a.handlePasteView)
@@ -315,11 +318,26 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{ writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType, "id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt, "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, "view_count": row.ViewCount, "visibility": row.Visibility,
"reads_remaining": rem, "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) { func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id) row, err := a.store.GetPaste(id)
@@ -376,6 +394,33 @@ func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) boo
row.ViewerID.String != "" && row.ViewerID.String == vid 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). // handleListMine serves /api/mine: pastes created from this browser (#37).
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) { func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
vid := currentViewerID(r) vid := currentViewerID(r)
@@ -394,7 +439,7 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
for _, row := range rows { for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title) lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{ 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, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility, "custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
"is_can": row.IsCan, "is_can": row.IsCan,
@@ -415,7 +460,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
for _, row := range rows { for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title) lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{ 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, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "custom_slug": store.NullStrPtr(row.CustomSlug),
"is_can": row.IsCan, "is_can": row.IsCan,
@@ -451,6 +496,25 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", 404) http.Error(w, "not found", 404)
return return
} }
// #221: 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 // #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 // 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 — // render as active content on this origin when fetched from /raw —
+4 -1
View File
@@ -26,7 +26,10 @@ type Attachment struct {
SizeHuman string `json:"-"` // template-only: human-readable size 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. // ErrFileTooLarge is returned when an attachment exceeds the per-file cap.
var ErrFileTooLarge = errors.New("file too large") 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 { if got := SanitizeFilename(long); len(got) != MaxFilenameLen {
t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen) t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen)
} }
// issue #248: a 250-char multipart filename must truncate to the cap
repro := strings.Repeat("b", 246) + ".txt"
if got := SanitizeFilename(repro); len(got) != MaxFilenameLen {
t.Errorf("repro name len = %d want %d", len(got), MaxFilenameLen)
}
} }
+1
View File
@@ -13,6 +13,7 @@ var reservedSlugs = map[string]bool{
"api": true, "raw": true, "can": true, "cans": true, "public": true, "api": true, "raw": true, "can": true, "cans": true, "public": true,
"history": true, "static": true, "assets": true, "favicon.ico": true, "history": true, "static": true, "assets": true, "favicon.ico": true,
"new": true, "login": true, "logout": true, "admin": true, "settings": 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") var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
+13 -10
View File
@@ -63,6 +63,7 @@ type PasteRow struct {
DeletionToken sql.NullString DeletionToken sql.NullString
ViewerID sql.NullString ViewerID sql.NullString
IsCan bool // set on list rows that are cans (#4) IsCan bool // set on list rows that are cans (#4)
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
} }
type CanRow struct { type CanRow struct {
@@ -304,11 +305,12 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
// listed, and password-protected pastes are excluded at the query level // listed, and password-protected pastes are excluded at the query level
// (#65) so their metadata (title, slug, existence) never leaks. // (#65) so their metadata (title, slug, existence) never leaks.
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { 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 rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
FROM pastes FROM pastes p
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 > ?) LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL UNION ALL
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1 SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans FROM paste_cans
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) 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) ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
@@ -321,7 +323,7 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
var r PasteRow var r PasteRow
var cs, lang, title sql.NullString var cs, lang, title sql.NullString
var isCan int 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, &isCan, &r.AttFilename); err != nil {
return nil, 0, err return nil, 0, err
} }
r.CustomSlug = cs r.CustomSlug = cs
@@ -339,11 +341,12 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
// ListMine lists pastes created from the given viewer id (browser cookie), newest first. // 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) { 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 rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
FROM pastes FROM pastes p
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND p.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL UNION ALL
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1 SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans FROM paste_cans
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) 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) ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
@@ -356,7 +359,7 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
var r PasteRow var r PasteRow
var cs, lang, title sql.NullString var cs, lang, title sql.NullString
var isCan int 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, &isCan, &r.AttFilename); err != nil {
return nil, 0, err return nil, 0, err
} }
r.CustomSlug, r.Language, r.Title = cs, lang, title r.CustomSlug, r.Language, r.Title = cs, lang, title
+121 -18
View File
@@ -216,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; } .tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; }
.paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; } .paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; word-break: normal; overflow-wrap: anywhere; } .paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; word-break: normal; overflow-wrap: anywhere; }
.stats-pill { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } /* #222: .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 { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; border-bottom: 1px solid var(--border); color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 14px 18px; cursor: pointer; text-align: left; }
.stats-head:hover { color: var(--fg); background: var(--surface-2); } .stats-head:hover { color: var(--fg); background: var(--surface-2); }
.stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; } .stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; }
@@ -296,8 +299,14 @@ html[data-wrap] .float { overflow-x: hidden; }
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } .code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.code { .code {
font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh); 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 /* #167: the gutter must not drive the flex layout — its content width
(row count × number width) shrinks the code column, which re-wraps lines, (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 which grows the gutter: a feedback loop. Pin the gutter with a fixed
@@ -307,11 +316,13 @@ html[data-wrap] .float { overflow-x: hidden; }
.code .gutter { flex-shrink: 0; } .code .gutter { flex-shrink: 0; }
/* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */ /* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */
.code .gutter { padding-top: 0; padding-bottom: 0; } .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 */ /* #167: each logical line is its own block so offsetTop identifies its first visual row */
.codeline { display: block; } .codeline { display: block; }
/* #167 rev: gutter number spans must stack one per visual row (wrap on) */ /* #167: gutter number spans must stack one per visual row (wrap on).
.code .gutter .gutline { display: block; } #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 /* #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 */ empty block (blank source line) needs its own line box to stay one row */
.codeline:empty::before { content: "\200B"; } .codeline:empty::before { content: "\200B"; }
@@ -343,10 +354,15 @@ tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; } tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); } tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); } 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.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); } 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. */ /* 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.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 { 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); } .badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; } .dim { color: var(--muted-fg); white-space: nowrap; }
@@ -432,13 +448,15 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
/* protection section rhythm (#20) */ /* protection section rhythm (#20) */
.protect { display: flex; flex-direction: column; gap: 2px; } .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, /* submenu number boxes (#157): styled number inputs + unit selects,
padded + indented to line up with parent option labels */ padded + indented to line up with parent option labels */
.submenu { .submenu {
margin: 6px 0 4px 8px; /* #240: indent the box under the parent option LABEL text (8px row padding
padding: 8px 10px; + 16px control + 8px gap = 32px), and match the row control padding rhythm */
margin: 4px 0 4px 32px;
padding: 6px 10px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@@ -515,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 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 { 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); } .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 { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); } td .id-link:hover { color: var(--accent); }
@@ -575,6 +593,20 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
border-color: var(--ok); 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) */ /* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 { .settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em; letter-spacing: -0.01em;
@@ -809,23 +841,64 @@ button[type="submit"]:focus-visible,
font-size: 22px; padding: 0 4px; border-radius: var(--radius-sm); font-size: 22px; padding: 0 4px; border-radius: var(--radius-sm);
} }
.file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); } .file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); }
.attachment-bar { display: flex; flex-direction: column; gap: 10px; } .attachment-bar { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
.attachment-chip { /* #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; display: inline-flex; align-items: center; gap: 12px; align-self: flex-start;
border: 1px solid var(--border); border-radius: var(--radius); border: 1px solid var(--border); border-radius: var(--radius);
padding: 8px 16px; text-decoration: none; color: var(--fg); padding: 6px 14px; text-decoration: none; color: var(--fg);
background: var(--surface-2); font-size: 21.6px; 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') */ /* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
.hidden { display: none; } .hidden { display: none; }
.can-page { max-width: 900px; width: 100%; } .can-page { max-width: 900px; width: 100%; }
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } .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-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
.col-f { width: 100px; } .col-g { width: 190px; } .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, /* #210: /mine rows render a delete button cell that had no declared column,
so under table-layout:fixed it overlapped the ID column. */ so under table-layout:fixed it overlapped the ID column. */
.col-del { width: 64px; } .col-del { width: 64px; }
@@ -841,3 +914,33 @@ button[type="submit"]:focus-visible,
.created-banner { display: block; } .created-banner { display: block; }
/* #167: gutter rows for wrapped paste view — one row per visual code line */ /* #167: gutter rows for wrapped paste view — one row per visual code line */
.gutline { display: block; } .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; }
+3 -3
View File
@@ -6,12 +6,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${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>${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="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<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>` + `<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>`, `<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.', emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.', 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);
}
})();
+3 -3
View File
@@ -18,12 +18,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${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>${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="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<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>` + `<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 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>`, `<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.', emptyFiltered: 'No pastes from this browser match your search.',
+78 -19
View File
@@ -2,13 +2,73 @@
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter'); 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() { 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 = ''; let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n'; for (let i = 1; i <= n; i++) s += i + '\n';
gutter.textContent = s; 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); 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(); updateGutter();
function toast(msg, kind) { function toast(msg, kind) {
@@ -190,15 +250,16 @@ async function create() {
// button, password auto-unlock, then redirect to the paste. // button, password auto-unlock, then redirect to the paste.
function finishCreate(data) { function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id); 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 || ''; $('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy'); const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => { copyBtn.addEventListener('click', () => {
try { try {
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53) copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!'; setTimeout(() => copyBtn.classList.remove('ok'), 2000);
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); } } catch(e) { toast('Copy failed', 'error'); }
}); });
// token carried via sessionStorage, never in the URL (#143) // token carried via sessionStorage, never in the URL (#143)
@@ -307,21 +368,19 @@ function setAttachedFile(file) {
attachedFile = file; attachedFile = file;
renderFileChip(); renderFileChip();
setHidden('file-text-note', false); setHidden('file-text-note', false);
// #171/#184: title auto-fill — images take the file name; text files // #233: title auto-fill — the file's own name always wins when the
// use the language-placeholder convention (e.g. Python.py, fallback // title is still blank; fallback is date.fileextension (e.g.
// Text.txt). Only when the title is still blank; never overwrite a // 2026-09-10.txt) when the name is missing or unusable. Never
// typed title. // overwrite a typed title.
if (!$('title').value.trim()) { if (!$('title').value.trim()) {
if (IMAGE_RE.test(file.type)) { const raw = (file.name || '').trim();
$('title').value = file.name; if (raw) {
$('title').value = raw;
} else { } else {
const lang = LANG_BY_EXT[extOf(file.name)]; const d = new Date();
if (lang) { const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const fn = defaultFilename(lang); const ext = extOf(raw || file.name);
if (fn) $('title').value = fn; $('title').value = ext ? iso + '.' + ext : iso;
} else if (TEXT_EXTS.has(extOf(file.name))) {
$('title').value = defaultFilename('text') || 'Text.txt';
}
} }
} }
showFileInEditor(file); showFileInEditor(file);
+17
View File
@@ -44,6 +44,23 @@
// measured, not derived from span counts or heights. // measured, not derived from span counts or heights.
function renumber() { function renumber() {
var lines = body.querySelectorAll('.codeline'); 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) { if (!wrapOn() || !lines.length) {
// wrap OFF: one number per logical line (pre-existing behavior, // wrap OFF: one number per logical line (pre-existing behavior,
// including the gutter scrolling with horizontal scroll). // including the gutter scrolling with horizontal scroll).
+20 -9
View File
@@ -16,17 +16,26 @@ function toggleStats() {
pill.classList.toggle('open', open); pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
function copyContent(btn) { function copyFeedback(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value); if (!btn) return;
// in-place success feedback (#53) // #260 attempt 2: .swapbtn stacks the label and checkmark in the same grid
if (btn) { // 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.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh); clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000); btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000);
} else {
toast('Link Copied', 'success');
} }
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() { function redeem() {
if (!confirm('Hard delete this paste immediately?')) return; if (!confirm('Hard delete this paste immediately?')) return;
@@ -34,13 +43,15 @@ function redeem() {
try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {} try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {}
if (!tok) { alert('deletion token not available in this browser'); return; } if (!tok) { alert('deletion token not available in this browser'); return; }
fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}}) 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) // wiring (moved from inline handlers for CSP #139)
var PASTE_ID = document.currentScript.getAttribute('data-paste-id'); var PASTE_ID = document.currentScript.getAttribute('data-paste-id');
var copyBtn = document.getElementById('copy-btn'); var copyBtn = document.getElementById('copy-btn');
if (copyBtn) copyBtn.addEventListener('click', function (e) { e.preventDefault(); copyContent(copyBtn); }); 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'); var delBtn = document.getElementById('delete-btn');
// #168: show the compact paste-created pill in the bottom corner, then fade it out // #168: show the compact paste-created pill in the bottom corner, then fade it out
+4 -1
View File
@@ -73,7 +73,10 @@
}); });
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var dark = state().dark; 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) {} try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true'); btn.setAttribute('aria-pressed', 'true');
+3 -3
View File
@@ -16,7 +16,7 @@ const PaletteTable = (() => {
const sortVal = (it, k) => { const sortVal = (it, k) => {
let v = it[k]; let v = it[k];
if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase(); 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; if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v;
return v == null ? null : v; return v == null ? null : v;
}; };
@@ -104,7 +104,7 @@ const PaletteTable = (() => {
const btns = []; const btns = [];
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`); 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}); 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; let last = 0;
for (let i = 1; i <= filtPages; i++) { for (let i = 1; i <= filtPages; i++) {
if (win.has(i)) { if (win.has(i)) {
@@ -121,7 +121,7 @@ const PaletteTable = (() => {
const btns = []; const btns = [];
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`); 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}); 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; let last = 0;
for (let i = 1; i <= pages; i++) { for (let i = 1; i <= pages; i++) {
if (win.has(i)) { if (win.has(i)) {
+2 -2
View File
@@ -8,10 +8,10 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div> <div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <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> <thead><tr>
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th> <th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
<th data-sort="language" class="sortable">Language<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="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="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="created_at" class="sortable">Created<span class="sort-ind"></span></th>
+3 -3
View File
@@ -8,11 +8,11 @@
{{define "topbar"}} {{define "topbar"}}
<div class="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> <nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a> <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="/public" {{if eq .Page "public"}}class="on"{{end}}>Public</a>
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</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> <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> </nav>
<div class="spacer"></div> <div class="spacer"></div>
+2 -2
View File
@@ -8,10 +8,10 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div> <div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <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"><col class="col-del"></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> <thead><tr>
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th> <th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
<th data-sort="language" class="sortable">Language<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="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="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="custom_slug" class="sortable">URL<span class="sort-ind"></span></th>
+6 -1
View File
@@ -19,7 +19,7 @@
<option>markdown</option><option>text</option> <option>markdown</option><option>text</option>
</select> </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 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> </div>
</div> </div>
@@ -33,6 +33,10 @@
<div class="spacer spacer-flex"></div> <div class="spacer spacer-flex"></div>
<button class="btn" id="create">Create</button> <button class="btn" id="create">Create</button>
</div> </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>
<div class="pane-r"> <div class="pane-r">
@@ -90,4 +94,5 @@
</div> </div>
</div> </div>
<script src="/static/new.js" defer></script> <script src="/static/new.js" defer></script>
<script src="/static/jump.js" defer></script>
{{template "foot" .}} {{template "foot" .}}
+16 -8
View File
@@ -6,10 +6,11 @@
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1> <h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}} {{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
<div class="spacer"></div> <div class="spacer"></div>
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button> <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="/raw/{{.ID}}">Raw</a>
<a class="iconbtn" href="#" id="copy-btn">copy</a> <a class="iconbtn swapbtn" href="#" id="copy-link-btn"><span class="swap-label">Link</span><span class="swap-check"></span></a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">delete</a>{{end}} <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> </div>
<div class="float"> <div class="float">
@@ -20,7 +21,7 @@
</button> </button>
<div class="stats-body" id="stats-body" hidden> <div class="stats-body" id="stats-body" hidden>
<div class="stats-grid"> <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">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">Views</span><span class="stats-v">{{.ViewCount}}</span>
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span> <span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
@@ -39,21 +40,28 @@
{{if .Attachment}} {{if .Attachment}}
<div class="float"> <div class="float">
<div class="attachment-bar"> <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}}"> <a class="attachment-chip" href="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" data-mime="{{.Attachment.Mime}}">
<span class="attachment-name">{{.Attachment.Filename}}</span> <span class="attachment-name">{{.Attachment.Filename}}</span>
<span class="attachment-size">{{.Attachment.SizeHuman}}</span> <span class="attachment-size">{{.Attachment.SizeHuman}}</span>
</a> </a>
{{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}}
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
{{if not .Attachment}}
<div class="float"> <div class="float">
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div> <div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
</div> </div>
{{end}}
<div 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> </div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}"> <input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script> <script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
<script src="/static/paste-lines.js" defer></script> <script src="/static/paste-lines.js" defer></script>
<script src="/static/jump.js" defer></script>
{{template "foot" .}} {{template "foot" .}}
+58 -8
View File
@@ -231,6 +231,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) { func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
lines := strings.Count(row.Content, "\n") + 1 lines := strings.Count(row.Content, "\n") + 1
gutter := "" gutter := ""
@@ -245,27 +259,62 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC
if lang == "" { if lang == "" {
lang = "text" lang = "text"
} }
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt))
// #38: one optional file attachment per paste; nil when none. // #38: one optional file attachment per paste; nil when none.
attachment, err := h.Store.GetAttachmentForPaste(row.ID) attachment, err := h.Store.GetAttachmentForPaste(row.ID)
if err != nil { if err != nil {
http.Error(w, "db error", 500) http.Error(w, "db error", 500)
return return
} }
// #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{ data := map[string]any{
"Page": "paste", "Page": "paste",
"ID": row.ID, "ID": row.ID,
"Title": row.Title.String, "Title": row.Title.String,
"Language": row.Language.String, "Language": typeLabel,
"TypeLabel": typeLabel,
"HasAttachment": attachment != nil,
"AttachmentExt": attExt,
"StatsSummary": summary, "StatsSummary": summary,
"SizeHuman": humanSize(len(row.Content)), "SizeHuman": sizeHuman,
"HasPassword": row.PasswordHash.Valid, "HasPassword": row.PasswordHash.Valid,
"BurnAfterRead": row.BurnAfterRead, "BurnAfterRead": row.BurnAfterRead,
"CustomSlug": row.CustomSlug.String, "CustomSlug": row.CustomSlug.String,
"ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text "ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text
"ContentAttr": row.Content, "ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"), "Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines, "LineCount": lineCount,
"SizeBytes": len(row.Content), "SizeBytes": len(row.Content),
"CreatedAgo": agoString(row.CreatedAt), "CreatedAgo": agoString(row.CreatedAt),
"CreatedAtUnix": row.CreatedAt, "CreatedAtUnix": row.CreatedAt,
@@ -279,6 +328,7 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC
"ReadsTotal": int(row.ReadsLimit.Int64), "ReadsTotal": int(row.ReadsLimit.Int64),
"JustCreated": justCreated, "JustCreated": justCreated,
"Attachment": attachment, "Attachment": attachment,
"AttachmentImage": attImage,
"Host": "this host", "Host": "this host",
} }
h.renderPage(w, "paste.html", data) h.renderPage(w, "paste.html", data)
@@ -383,9 +433,9 @@ func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "new.html", map[string]any{"Page": "new"}) 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) { 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. // HandleSettingsPage serves /settings.
@@ -402,9 +452,9 @@ func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes}) 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) { 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. // HandleAdminPage serves /admin.