81 Commits
Author SHA1 Message Date
fen 7cc6a9c706 Add line wrap toggle for editor and paste viewer (#130)
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
Shared client-side preference in localStorage 'palette-wrap', default
off. Toggle button on /new editor head and paste title bar, plus a Line
wrap checkbox in the settings menu; all read/write the same preference.
Active state uses the accent treatment like other toggles. No em dashes
in UI text.
2026-09-09 22:35:49 -05:00
fen 19cb8d1d45 Merge pull request 'Theme pairs with dark mode toggle, gear active state, PALETTE_DEFAULT_DARK' (#128) from fix-theme-pairs-darkmode into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 32s
2026-09-10 03:25:35 +00:00
fen dcf2d66a82 Theme pairs with dark mode toggle, gear active state, PALETTE_DEFAULT_DARK (#127)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
- Collapse the 10 theme cards into 5 pairs: light swatches top row, dark bottom row
- Dark mode toggle in settings and topbar (sun/moon inline SVG)
- Toggling dark mode switches to the other variant of the selected pair
- Persist pair id in localStorage palette-theme, dark flag in palette-dark
- Head script resolves base+dark to variant; URL ?theme= accepts both ids and wins
- Settings gear highlights like nav tabs on /settings
- PALETTE_DEFAULT_DARK env var sets server default dark state (default on)
- Server-side resolution helpers + tests
2026-09-09 22:17:50 -05:00
fen f1b5dd0364 Merge pull request 'Remove custom URL reservation note and Can contents menu' (#126) from fix-124-remove-note-can-menu into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 40s
2026-09-09 22:05:05 -05:00
fen 8eabeb4839 Merge dev into fix-124: resolve new.html conflict, keep #124 removals and #125 placeholder
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-09 21:53:00 -05:00
fen 7ed50087a6 Merge pull request 'UI polish: custom URL placeholder, styled buttons/selects, admin link spacing' (#125) from fix-ui-polish into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 35s
2026-09-10 02:49:36 +00:00
fen 8104cdba4c Remove custom URL reservation note and Can contents menu from new paste page
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
Fixes #124
2026-09-09 21:47:50 -05:00
fen be3ff81e5c base 2026-09-09 21:45:42 -05:00
fen 1e317d11da UI polish: placeholder slash, styled controls, admin link spacing (#123)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 21:45:22 -05:00
fen 2ce50a6393 Merge pull request 'Fix #120: add matching light/dark variant for every theme' (#121) from fix-theme-variants into dev
CI / test (push) Successful in 27s
CI / docker (push) Successful in 35s
2026-09-10 02:36:47 +00:00
fen 70c284b15d Fix #120: add matching light/dark variant for every theme
CI / test (pull_request) Successful in 31s
CI / docker (pull_request) Skipped
2026-09-09 21:33:56 -05:00
fen 0ceeabc810 Merge dev: burn fix, version label, theme persistence, admin/settings UI, theme grid, CSP fix, docs
CI / test (push) Successful in 21s
CI / docker (push) Successful in 47s
2026-09-09 21:32:30 -05:00
fen aca99bc6d4 Merge pull request 'Fix #116: allow inline styles in CSP (style-src)' (#117) from fix-116-csp-inline-styles into dev
CI / test (push) Successful in 22s
CI / docker (push) Successful in 36s
2026-09-10 02:20:53 +00:00
fen 073d39ccba Fix #116: allow inline styles in CSP (style-src 'self' 'unsafe-inline')
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 21:18:53 -05:00
fen 9ec7d5152e Merge pull request 'Fix #114: theme grid empty (t.colors undefined)' (#115) from fix-114-theme-grid-empty into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 42s
2026-09-10 02:01:38 +00:00
fen a677e627a8 Fix #114: use presetColors(t.id) for theme card swatches
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
t.colors was removed by #113; rendering threw on first iteration and
the theme grid rendered empty. Use the colors returned by
presetColors(t.id) instead. Verified in headless chromium: 5
theme-card elements render on /settings.
2026-09-09 20:55:13 -05:00
fen 7a2fa00f4a Merge pull request 'Fix admin lock-on-load, settings hint, theme swatches (#112)' (#113) from fix-112-admin-settings-ui into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 34s
2026-09-10 00:54:43 +00:00
fen 6b95994c53 Admin lock on every load, drop settings hint, derive theme swatches from CSS vars (#112)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 19:52:32 -05:00
fen 7fde147500 Merge pull request 'Fix theme persistence on reload' (#109) from fix-100-theme-persist into dev
CI / test (push) Successful in 23s
CI / docker (push) Successful in 38s
2026-09-10 00:22:04 +00:00
fen cf8674b898 Merge pull request 'Fix wiki docs accuracy (#110)' (#111) from fix-110-docs-accuracy into dev
CI / test (push) Successful in 21s
CI / docker (push) Successful in 32s
2026-09-10 00:20:17 +00:00
fen f6e81de2a3 Fix README: build path ./cmd/palette, Go 1.27+, design-doc wiki link (#110)
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-09 19:15:44 -05:00
fen e17ea4f8c2 Move docs to Gitea wiki; point README links at wiki
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 18:52:41 -05:00
fen 5afe81f34c README: AI-built disclaimer before feature list
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 18:46:15 -05:00
poslop 097ffb7971 Update README.md
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 23:44:37 +00:00
poslop 815635a973 Update README.md
CI / test (push) Successful in 28s
CI / docker (push) Skipped
2026-09-09 23:42:06 +00:00
fen df73d56e32 Merge remote-tracking branch 'origin/fix-93-version-label' into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 49s
2026-09-09 18:41:26 -05:00
fen 9d89995c91 Merge pull request 'store: burn_after_reads>0 implies burn-after-read (#82)' (#107) from fix-82-burn-implies-budget into dev
CI / test (push) Successful in 24s
CI / docker (push) Successful in 37s
2026-09-09 23:40:22 +00:00
fen 805cc3ff2b web: restore persisted theme on page load (#100)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 18:40:09 -05:00
poslop f37cc932c4 store: burn_after_reads>0 implies burn-after-read mode (#82)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-09 18:28:00 -05:00
agent 65ed0c7043 Show actual version in topbar instead of hardcoded beta (Fixes #93)
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-09 18:27:57 -05:00
poslop 8ecf5338d5 CI: build dev image on dev branch pushes (palette-dev deploy)
CI / test (push) Successful in 25s
CI / docker (push) Successful in 42s
2026-09-09 18:22:31 -05:00
poslop 69da340224 Settings page: theme switcher with live preview (#100)
CI / test (push) Successful in 23s
CI / docker (push) Skipped
2026-09-09 18:21:45 -05:00
poslop b1af398bea Update README.md
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-09 22:56:50 +00:00
poslop 15f199b2fe README: trim to brief front page + retake themed screenshots (#106)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
- Cut Admin walkthrough and API curl block to one-liners pointing at docs
- Keep Configuration table, features, Get Started, doc links
- Retake all screenshots against current UI, one theme each:
  /new midnight, /history pastel-lavender, paste view pastel-peach,
  /mine pastel-cloud; remove superseded stale previews
2026-09-09 17:30:54 -05:00
poslop 9df6224a27 Result box: color-code by status + friendly error messages from error codes (#105)
CI / test (push) Successful in 23s
CI / docker (push) Skipped
- Backend create/can validation paths emit machine-readable error codes
  (slug_taken, slug_invalid, content_empty, content_too_large,
  expiry_invalid, rate_limited, ...) alongside the human message
- new.html JS maps codes to plain-language guidance with generic fallback
- Result card colored via --ok/--err/--warn left border (result-ok/err/warn)
- docs/API.md error section documents the code field
- Tests assert the code on every validation path
2026-09-09 17:28:25 -05:00
poslop c35531e03e Settings page: small muted Admin link (#102)
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 17:17:00 -05:00
poslop be2b4fb47f Sort arrows: left of label, further spaced, vertically centered, no label shift (#101)
Arrow is absolutely positioned at left:8px in the th, label padding-left
24px; translateY(-50%) keeps it centered regardless of row height, and
being out of flow the label never moves between sorted/unsorted states.
2026-09-09 17:17:00 -05:00
poslop 064f05536a Admin page: validate key before persisting to sessionStorage (#99)
Panel was already display:none-gated until auth; tightened so a wrong
submitted key is never stored in sessionStorage — the typed key is
validated via the settings API first, and only persisted on 200.
2026-09-09 17:17:00 -05:00
poslop 4175ffef3e CSS: inline sort arrow with uniform 8px gap (#103), full-pill slug roundness (#104)
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 17:10:24 -05:00
poslop ea9edc211a UI: consistent sort arrow gap, uniform pill roundness (slug -> radius-sm)
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 16:50:06 -05:00
poslop 6d467312af Merge minor fixes: #92-#98, #100 theme switcher + cans work
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 16:40:53 -05:00
poslop b21be29daa minor fixes: version label (#93), view dedupe tests (#95) 2026-09-09 16:40:53 -05:00
poslop cd70dd0b0e Merge origin/main (license, compose, #65 leak guard) into cans work
CI / test (push) Failing after 6s
CI / docker (push) Skipped
2026-09-09 16:39:14 -05:00
poslop 5b9be808ed Merge origin/main: keep can listing rows with #65 password-metadata leak guard 2026-09-09 16:36:55 -05:00
poslop 19804d47a3 Cans UI + parity: unlock-cookie flow, /can page, listings badge, custom slug, delete, sweeper; #32 perf notes (#4, #32) 2026-09-09 16:34:51 -05:00
poslop ffc10c2b86 README: link to docker-compose example
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-09 16:16:28 +00:00
poslop bf5a79878f add example docker-compose.yml documenting all env vars
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-09 16:16:15 +00:00
poslop 60200ac14b README: license section with as-is / no-liability disclaimer
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-09 16:12:41 +00:00
poslop 49f7b6e7a4 add AGPLv3 license with copyright and warranty disclaimer notice
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-09 16:12:19 +00:00
poslop 959a83be1e Merge pull request 'Bound title and language at create time (#86)' (#91) from fix-86-field-bounds into main
CI / test (push) Successful in 24s
CI / docker (push) Successful in 42s
2026-09-09 15:59:32 +00:00
poslop 5b068faf8f Merge pull request 'ratelimit: rightmost untrusted-hop-safe client IP (fixes #85)' (#89) from fix-85-xff into main
CI / test (push) Successful in 27s
CI / docker (push) Skipped
2026-09-09 15:59:29 +00:00
poslop b0360af79c Merge pull request 'Fix #83: create API ignores boolean public field' (#88) from fix-83-public-false into main
CI / test (push) Successful in 26s
CI / docker (push) Skipped
2026-09-09 15:59:26 +00:00
poslop 806798fae6 Merge pull request 'Rate-limit all password verification attempts (#81)' (#90) from fix-81-password-ratelimit into main
CI / test (push) Successful in 26s
CI / docker (push) Skipped
2026-09-09 15:59:24 +00:00
poslop 8977c05cda Merge pull request 'fix #84: security headers were silently dropped (set pre-handler)' (#87) from fix-84-headers-prewrite into main
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 15:59:21 +00:00
fen 389e4d333f Bound title and language at create time (#86)
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
Titles are trimmed and truncated to 200 characters; language must be
blank or match ^[a-zA-Z0-9+#-]{1,40}$ (max 40 chars) or the create is
rejected with a clear 400. Regression tests cover truncation, malformed
and oversized language values, valid identifiers, and blank metadata.
2026-09-09 10:58:34 -05:00
fen 99a044e8a2 Rate-limit all password verification attempts (#81)
CI / test (pull_request) Successful in 22s
CI / docker (pull_request) Skipped
GET ?password= and X-Paste-Password header checks in handleGetPaste were
unlimited (pentest2: 8 wrong = 8x401, no 429). Gate every password
verification on the same per-IP+paste unlock limiter (5/min, 429 with
Retry-After) already used by the POST unlock form path.

Regression tests: repeated wrong passwords via GET ?password= and via
header must eventually 429; correct password within burst still 200.

Fixes #81
2026-09-09 10:58:21 -05:00
agent 3d1d5ee1fd ratelimit: key on rightmost X-Forwarded-For entry (fixes #85)
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-09 10:57:59 -05:00
Hermes Agent ccb22a2d28 #83: accept boolean public field in create API (false->unlisted, true->public)
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-09 10:57:56 -05:00
fen 3d415edbdc fix #84: security headers dropped because they were set post-flush; set pre-handler
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-09 10:54:16 -05:00
poslop 7ca1b58362 Merge pull request 'docs: attachments & storage backend design (#38, #31)' (#69) from issue-38-31-storage-design into main
CI / test (push) Successful in 19s
CI / docker (push) Successful in 34s
2026-09-09 14:27:50 +00:00
poslop 8d0825ac09 Merge pull request 'docs: cookie-based preferences and access keys design (#30)' (#70) from issue-30-cookie-design into main
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-09 14:27:14 +00:00
poslop 4cb8a3e127 Merge pull request 'Search: loading indicator and performance note (#32)' (#72) from issue-32-search-indicator into main
CI / test (push) Successful in 30s
CI / docker (push) Skipped
2026-09-09 14:26:25 +00:00
poslop 91dbf6f344 Merge pull request 'fix: input validation gaps (#68)' (#78) from issue-68-input-validation into main
CI / test (push) Successful in 23s
CI / docker (push) Skipped
2026-09-09 14:26:19 +00:00
poslop b600cdf51a Merge pull request 'Admin key endpoint: per-IP rate limiting and lockout (#66)' (#73) from issue-66-admin-ratelimit into main
CI / test (push) Successful in 24s
CI / docker (push) Skipped
2026-09-09 14:26:13 +00:00
poslop 47c9fa6386 Merge pull request 'Clamp expires_in at API boundary (#60)' (#74) from issue-60-expiry-clamp into main
CI / test (push) Successful in 23s
CI / docker (push) Skipped
2026-09-09 14:26:07 +00:00
poslop b7d1119d6d Merge pull request 'docs: design for optional client-side E2E encryption (issue #39)' (#75) from issue-39-e2e-design into main
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-09 14:26:02 +00:00
poslop 44fe3c5772 Merge pull request 'Security headers middleware: CSP, Referrer-Policy, nosniff (#59)' (#76) from issue-59-security-headers into main
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 14:26:01 +00:00
poslop 556d0fa2e1 Merge pull request 'fix #63: require deletion token on DELETE /api/pastes/{id}' (#79) from issue-63-delete-auth into main
CI / test (push) Successful in 18s
CI / docker (push) Skipped
2026-09-09 14:25:40 +00:00
poslop bdd5235f2e Merge pull request 'fix: atomic burn-after-read claim (#58)' (#77) from issue-58-burn-race into main
CI / test (push) Successful in 18s
CI / docker (push) Skipped
2026-09-09 14:25:06 +00:00
poslop 91568c0598 fix #63: require deletion token on DELETE /api/pastes/{id}
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
- DELETE now demands the create-time deletion token (Authorization
  header: Bearer/Token/bare, or ?token= query param), compared with
  the constant-time store.DeletionTokenEqual. 403 otherwise.
- Creator-browser deletes via the /mine button (matching vwr cookie,
  #37) remain allowed; other browsers and plain API clients get 403.
- Regression tests: no token, wrong token (header+query), correct
  token (header+query), creator-cookie path, token extraction.
- Adapted TestSoftDelete to pass the deletion token.
- docs/API.md delete section updated.
- Based on #58's SoftDelete (bool, error) signature.
2026-09-09 09:21:28 -05:00
agent 94a4a3c2ec fix: input validation gaps (#68)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
- enforce server-side body cap via http.MaxBytesReader: oversized JSON
  bodies are rejected with 413 instead of being fully decoded first
- negative burn_after_reads rejected with 400 (zero still = default 1)
- limit=0 explicitly maps to default page size; shared parseLimit clamp
  for /api/public and /api/mine (huge/non-numeric values too)
- negative/non-numeric offset clamped to 0 via parseOffset (was
  pass-through)
- regression tests in issue68_validation_test.go

expires_at/expires_in validation intentionally excluded: covered by #60.
2026-09-09 09:21:25 -05:00
agent 78374b2d49 fix: atomic burn-after-read claim (#58)
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
SoftDelete now reports whether it performed the delete (conditional
UPDATE ... WHERE deleted_at IS NULL checked via RowsAffected).
RegisterRead returns an admitted flag: legacy burn pastes admit exactly
one reader (the atomic soft-delete winner), and burn-after-N pastes
increment reads_used via a conditional UPDATE guarded on
reads_used < reads_limit, so concurrent readers cannot both consume the
final read. API, HTML, and raw read paths return 404 when the reader
loses the burn claim; content is never served twice.

OpenStore pins the SQLite pool to one connection: concurrent writes on
separate pooled connections surfaced SQLITE_BUSY as spurious 500s
instead of serializing.

Adds concurrency regression tests: 24 parallel readers of a burn paste
(exactly one receives content, none of the others leak it) and 30
parallel readers vs a 3-read budget (exactly 3 admitted, then 404).
2026-09-09 09:18:42 -05:00
palette-agent e08cafe9c8 security headers middleware: CSP, Referrer-Policy, nosniff on HTML pages (#59)
CI / test (pull_request) Successful in 30s
CI / docker (pull_request) Skipped
- web.SecurityHeaders middleware wired into the chi router
- Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' (page scripts are inline); frame-ancestors 'none'
- Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff
- Applied only to text/html responses; JSON API and /raw pass through unchanged
- Regression test internal/web/securityheaders_test.go
2026-09-09 09:16:42 -05:00
Hermes Agent 2159982795 docs: design for optional client-side E2E encryption (issue #39)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-09 09:16:13 -05:00
fen bb5c4f0186 fix #60: apply ValidExpiry clamp to cans create handler (was lost in working tree)
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
2026-09-09 09:15:39 -05:00
palette-agent 9811191648 admin: per-IP rate limit (5/min) on admin key attempts (#66)
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-09 09:15:01 -05:00
poslop 5a2aa0f96d docs: design note for cookie-based preferences and access keys (#30)
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-09 09:14:25 -05:00
poslop c288fc73a7 Search: spinner indicator + performance note on client-side filtering (#32)
CI / test (pull_request) Successful in 19s
CI / docker (pull_request) Skipped
- Fix filter fetch to request limit=100 (API max) instead of 500, which
  the API silently clamped, so filtered results actually cover the fetch window.
- Document client-side filtering behavior and limits in README Performance Notes.
2026-09-09 09:13:40 -05:00
Hermes Agent f6c4342468 Exclude password-protected pastes from public listings (#65)
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
ListPublic and its COUNT query now filter password_hash IS NULL, so
/api/public (and any page backed by it) no longer leaks metadata
(title, slug, existence) of password-protected pastes. Unlisted
pastes were already excluded. Adds regression test covering both.
2026-09-09 09:13:26 -05:00
poslop f611cc3e9e fix #60: clamp expires_in on cans API (400 on zero/negative, cap 1yr) 2026-09-09 09:13:21 -05:00
poslop 594b5f01aa docs: attachments & storage backend design (#38, #31)
CI / test (pull_request) Successful in 17s
CI / docker (pull_request) Skipped
- attachments: filesystem-on-PVC recommended, blob interface keeps MinIO
  as a later drop-in; size limits via admin setting; sniffed-mime +
  nosniff/sandbox serving rules
- storage backend: stay SQLite-only (WAL, modernc); no Postgres/Redis and
  no backend abstraction until documented trigger conditions fire
2026-09-09 09:10:15 -05:00
57 changed files with 3954 additions and 284 deletions
+5 -4
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: [main, dev]
tags: ['v*']
pull_request:
@@ -23,8 +23,8 @@ jobs:
run: go test -v ./...
docker:
# build & push image only on tags (releases)
if: startsWith(github.ref, 'refs/tags/')
# build & push image only on tags (releases) and dev branch (dev deploy)
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/dev'
needs: test
runs-on: [debian-latest]
env:
@@ -55,8 +55,9 @@ jobs:
with:
context: .
push: true
build-args: VERSION=${{ gitea.ref_name }}
tags: |
git.archfox.org/poslop/palette:${{ gitea.ref_name }}
git.archfox.org/poslop/palette:latest
${{ startsWith(gitea.ref, 'refs/tags/') && 'git.archfox.org/poslop/palette:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -1
View File
@@ -9,7 +9,8 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette ./cmd/palette
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X palette/internal/web.Version=${VERSION}" -o /palette ./cmd/palette
# ---- runtime stage ----
FROM alpine:3.20
+679
View File
@@ -0,0 +1,679 @@
Palette - self-hosted pastebin with paste cans
Copyright (C) 2026 poslop
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
SPDX-License-Identifier: AGPL-3.0-only
--------------------------------------------------------------------------------
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+59 -47
View File
@@ -1,33 +1,34 @@
# Palette
Palette is a fast, self-hosted pastebin. One Go binary, a SQLite database, and
a web UI for sharing code and text with links that expire on your terms.
a web UI for sharing text and small files
> [!NOTE]
> <table><tr><td>
> <sub>✨ This project was built by AI — <strong>GLM 5.3 Flash</strong> on the <strong>Hermes Agent</strong> harness, with a human in the loop.</sub>
> </td></tr></table>
## Features
- Paste cans — bundle notes, text, and files into one shareable page
- Password lock — protect individual pastes with a password
- Custom expiry — from 1 minute up to 1 year, or never
- Burn after N reads — a paste that vanishes after a chosen number of reads
- Custom URLs — reserve `/my-snippet` instead of a random slug
- Multiple files in one paste
- Password protected pastes
- Expir after a specified time
- Burn after a number of views
- Custom URLs
- Syntax highlighting with language auto-detection (go-enry)
- Rate limiting on create and unlock
- Saved page — see and manage everything created from your browser
- API-first — every UI action is also a plain HTTP call
- Single binary — templates and assets are embedded; no external deps
- Local cookie based submission history
- Cookie based settings
- Themes!
## Screenshots
| | |
|---|---|
| ![Editor in midnight](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmidnight-new.png) | ![Paste view in pastel-peach](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-peach-paste.png) |
| ![History in pastel-lavender](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-lavender-history.png) | ![Saved in pastel-cloud](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-cloud-mine.png) |
## Get Started
### Build from source
Requires Go 1.21+.
```bash
go build -o palette .
./palette
# open http://localhost:8080
```
### Docker
```bash
@@ -36,12 +37,22 @@ docker run -p 8080:8080 -v palette-data:/data git.archfox.org/poslop/palette
The SQLite database lives in the `/data` volume inside the container.
## Screenshots
## Docker Compose
See [docker-compose.yml](docker-compose.yml) for a ready-to-use example with
every environment variable documented, including which are required (only the
/data volume) and which are optional.
### Build from source
Requires Go 1.27+.
```bash
go build -o palette ./cmd/palette
./palette
# open http://localhost:8080
```
| | |
|---|---|
| ![Editor](docs/palette-previews/pastel-lavender-new.png) | ![Paste view](docs/palette-previews/pastel-lavender-paste.png) |
| ![History](docs/palette-previews/midnight-history.png) | |
## Configuration
@@ -52,35 +63,36 @@ The SQLite database lives in the `/data` volume inside the container.
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<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. |
### Admin
`GET /admin` serves the admin page. Enter the admin key there — it is stored in
`sessionStorage` (never a cookie) and sent as the `X-Admin-Key` header on
`GET`/`POST /admin/api/settings`.
The admin API reads/sets: rate-limit burst, rate-limit refill per minute, max
content bytes, default expiry, custom URL reservation days, and the burn
viewer window (minutes). All admin access attempts are logged.
```bash
./palette --reset-admin-key # regenerate the admin key and print it
```
An `/admin` page exists for runtime settings, protected by a key set at
install (`PALETTE_ADMIN_KEY` env var) and resettable locally — see
[API](https://git.archfox.org/poslop/palette/wiki/API) and the [design docs](https://git.archfox.org/poslop/palette/wiki/Home) in the wiki for details.
## API
Create a paste with one call:
Full REST API: [wiki/API](https://git.archfox.org/poslop/palette/wiki/API). One example:
```bash
curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \
-d '{"content": "print(hello)", "language": "python", "expires_in": "168h"}'
curl -X POST http://localhost:8080/api/pastes -d '{"content":"hello"}'
```
Full API docs: [docs/API.md](docs/API.md).
Design docs: [wiki/design](https://git.archfox.org/poslop/palette/wiki/design-attachments-storage) (e2e encryption, attachments & storage, cookie preferences).
## CI
Gitea Actions workflow at `.gitea/workflows/ci.yml`:
- On push to main: `go vet` + `go test`
- On tags: build and push Docker image to `git.archfox.org/poslop/palette`
## License
Palette is licensed under the GNU Affero General Public License v3.0
(AGPL-3.0-only). See [LICENSE](LICENSE).
The software is provided AS IS, without warranty of any kind, express or
implied, including merchantability and fitness for a particular purpose.
In no event shall the authors be liable for any claim, damages, or other
liability, including without limitation any security vulnerabilities,
data loss, or legal issues arising from use of the software. You use it
at your own risk.
If you run a modified version of Palette as a network service, the AGPL
requires you to offer your modified source code to its users.
+1
View File
@@ -42,6 +42,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
web.SetDefaultDark(os.Getenv("PALETTE_DEFAULT_DARK"))
srv := api.NewServer(st, cfg, ui, ss, adminKey)
log.Printf("palette listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
+46
View File
@@ -0,0 +1,46 @@
# Example docker-compose deployment for Palette.
# All environment variables are optional; sensible defaults apply.
# The only hard requirement is a volume on /data so the SQLite database
# and the auto-generated admin key survive restarts.
services:
palette:
image: git.archfox.org/poslop/palette:v0.2.2
# image: git.archfox.org/poslop/palette:latest # tracks main, less stable
restart: unless-stopped
ports:
- "8080:8080" # host:container; the app listens on :8080
volumes:
- palette-data:/data # REQUIRED: SQLite db, admin key, attachments
environment:
# Address the server binds to inside the container.
# Default: ":8080". Only change if you also change the ports mapping.
PALETTE_ADDR: ":8080"
# Path to the SQLite database file.
# Default: "/data/palette.db". Keep it on the /data volume.
PALETTE_DB: "/data/palette.db"
# Admin API key for /admin/api/settings (rate limits, size caps, expiry).
# Default: random key generated on first start and persisted to
# /data/admin-key (mode 0600). Read it with:
# docker compose exec palette cat /data/admin-key
# Set this only if you want a fixed key (e.g. for automation).
# PALETTE_ADMIN_KEY: "change-me"
# Max size in bytes of a single text paste. Oversized creates get 413.
# Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can).
# Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400"
# HMAC secret for password-unlock cookies. Default: random per start,
# which logs out every unlocked browser session on restart. Set a fixed
# secret (any random string) to keep unlock sessions across restarts,
# or when running multiple replicas that must agree.
# PALETTE_UNLOCK_SECRET: "generate-with-openssl-rand-base64-32"
volumes:
palette-data:
-107
View File
@@ -1,107 +0,0 @@
# Palette API
All endpoints are JSON unless noted. The web UI is served from the same port.
## Create paste
```bash
curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \
-d '{
"content": "print(hello)",
"title": "my snippet",
"language": "python",
"expires_in": "168h",
"password": "optional",
"custom_slug": "optional",
"burn_after_read": false,
"burn_after_reads": 1,
"visibility": "public"
}'
```
- `expires_in` is a Go duration string (`90m`, `6h`, `336h`). Omit for no expiry.
- `visibility` is `public` or `unlisted`.
- `burn_after_reads` sets how many reads the paste survives (default 1 when
`burn_after_read` is true). A read is counted per unique viewer session;
the same viewer returning within 15 minutes does not count again.
- Response includes `id`, `url`, `raw_url`, `api_url`, `expires_at`,
`created_at`, and a one-time `deletion_token`.
Errors: `400` invalid body/content too large/duplicate slug, `401` password
required, `404` paste expired/burned/gone, `413` content exceeds max bytes,
`429` rate limited.
## Get paste
```bash
curl http://localhost:8080/api/pastes/{id}
# password-protected pastes:
curl "http://localhost:8080/api/pastes/{id}?password=secret"
# or via header: X-Paste-Password: secret
```
The response includes `reads_remaining` (`null` when no read budget is set).
## Raw content
```bash
curl http://localhost:8080/raw/{id}
```
Raw reads count against a burn-after-read budget, same as page views.
## Delete
```bash
# soft delete (creator browser only; plain API clients unaffected)
curl -X DELETE http://localhost:8080/api/pastes/{id}
# hard delete immediately (requires the one-time deletion token)
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
```
## Lists
```bash
curl "http://localhost:8080/api/public?limit=25&offset=0" # public history
curl http://localhost:8080/api/mine # this browser's pastes (viewer cookie)
```
## Language detection
```bash
curl -X POST http://localhost:8080/api/guess-language \
-H "Content-Type: application/json" \
-d '{"content": "package main"}'
```
## Cans (bundles of items)
```bash
curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \
-F "expires_in=48h" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png" \
-F "files=@log.txt"
curl http://localhost:8080/api/cans/{id}
curl http://localhost:8080/api/cans/{id}/items/{item_id}
```
## Web pages
- `/new` — create a paste
- `/history` — public paste history
- `/mine` — pastes created from this browser
- `/{id}` — view a paste
- `/unlock/{id}` — password gate for protected pastes
- `/raw/{id}` — raw content with original content type
## Expiry and deletion
- Expired pastes are soft-deleted by a background sweeper (runs every minute).
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- Deletion tokens allow immediate hard delete.
- Burn-after-read pastes are soft-deleted once the read budget is exhausted.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

+5
View File
@@ -172,6 +172,11 @@ func (a *apiServer) adminKeyOK(r *http.Request, key string) bool {
func (a *apiServer) adminAuth(next http.HandlerFunc, key string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !rateLimitAdmin(r) {
log.Printf("admin auth RATE LIMITED: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
writeRateLimited(w, 60)
return
}
if !a.adminKeyOK(r, key) {
log.Printf("admin auth FAILURE: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
writeErr(w, 401, "unauthorized")
+80
View File
@@ -0,0 +1,80 @@
package api
// #66: admin key attempts must be rate limited per IP (5/min), constant-time
// compared, and failures logged. Hammering bad keys must yield 429s.
import (
"net/http/httptest"
"strings"
"testing"
)
// TestAdminKeyRateLimited: burst of 5 bad-key attempts allowed (401), the 6th
// gets 429, and even the correct key is blocked from that IP until refill.
func TestAdminKeyRateLimited(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.7.7.1:1234"
var got429, retryAfter bool
var lastCode int
for i := 0; i < 10; i++ {
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
req.RemoteAddr = reqIP
req.Header.Set("X-Admin-Key", "wrong-key")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
lastCode = rec.Code
if rec.Code == 429 {
got429 = true
retryAfter = rec.Header().Get("Retry-After") != ""
break
}
}
if !got429 {
t.Fatalf("expected 429 after hammering bad keys, last status %d", lastCode)
}
if !retryAfter {
t.Error("429 missing Retry-After header")
}
// Correct key from the same IP is also locked out.
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
req.RemoteAddr = reqIP
req.Header.Set("X-Admin-Key", srv.adminKey)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 429 {
t.Errorf("correct key after lockout: got %d, want 429", rec.Code)
}
// A different IP is unaffected.
req2 := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(`{"rate_limit_burst":5,"rate_limit_per_minute":60,"max_content_bytes":1048576,"custom_slug_reservation_days":30,"burn_viewer_window_minutes":15}`))
req2.RemoteAddr = "203.0.113.9:1234"
req2.Header.Set("X-Admin-Key", srv.adminKey)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
if rec2.Code != 200 {
t.Errorf("correct key from another IP: got %d, want 200", rec2.Code)
}
}
// TestAdminKeyConstantTimeCompare: sanity check that the comparison is
// constant-time (uses subtle.ConstantTimeCompare, not ==).
func TestAdminKeyConstantTimeCompare(t *testing.T) {
srv := newTestServer(t)
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Admin-Key", "test-admin-key")
if !srv.adminKeyOK(r, srv.adminKey) {
t.Fatal("correct key rejected")
}
r.Header.Set("X-Admin-Key", "wrong")
if srv.adminKeyOK(r, srv.adminKey) {
t.Fatal("wrong key accepted")
}
// differ in length: must not panic/mismatch unexpectedly
r.Header.Set("X-Admin-Key", "test-admin-key-longer")
if srv.adminKeyOK(r, srv.adminKey) {
t.Fatal("longer wrong key accepted")
}
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// TestBurnAfterReadConcurrentRace is the #58 regression test: N concurrent
// readers of a burn-after-read paste must receive exactly one success with
// content; every other reader must get 404 and never any content.
func TestBurnAfterReadConcurrentRace(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 1)
const readers = 24
var wg sync.WaitGroup
var mu sync.Mutex
wins, losses := 0, 0
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
mu.Lock()
defer mu.Unlock()
if rec.Code == 200 {
wins++
var got struct {
Content string `json:"content"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Content != "limited" {
t.Errorf("winning read returned wrong content: %v %q", err, got.Content)
}
} else if rec.Code == 404 {
losses++
if strings.Contains(rec.Body.String(), "limited") {
t.Errorf("losing read leaked content: %s", rec.Body.String())
}
} else {
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
}
}(i)
}
wg.Wait()
if wins != 1 {
t.Fatalf("expected exactly 1 winning read of burn paste, got %d (losses=%d)", wins, losses)
}
if losses != readers-1 {
t.Fatalf("expected %d losing reads, got %d", readers-1, losses)
}
}
// TestBurnAfterNReadsConcurrentBudget hammers a burn-after-N paste with many
// more concurrent distinct readers than the budget: total admissions must
// equal exactly N, and no losing read may see content.
func TestBurnAfterNReadsConcurrentBudget(t *testing.T) {
s := testServer(t)
h := s.routes()
const budget = 3
id := createBurnReads(t, h, budget)
const readers = 30
var wg sync.WaitGroup
var mu sync.Mutex
wins := 0
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
mu.Lock()
defer mu.Unlock()
if rec.Code == 200 {
wins++
} else if rec.Code != 404 {
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
}
}(i)
}
wg.Wait()
if wins != budget {
t.Fatalf("expected exactly %d admitted reads, got %d", budget, wins)
}
// After the race, the paste is burned for everyone.
if rec := getWithCookie(t, h, id, "after-the-fact"); rec.Code != 404 {
t.Fatalf("paste should be burned after budget exhausted, got %d", rec.Code)
}
}
+27
View File
@@ -46,6 +46,33 @@ func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.Resp
return rec
}
// #82: burn_after_reads > 0 alone must enable burn-after-read
// even without burn_after_read: true.
func TestBurnReadsImpliedByBurnAfterReads(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"implied","burn_after_reads":2}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create burn_after_reads-only: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 200 {
t.Fatalf("read 2: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "ccc"); rec.Code != 404 {
t.Fatalf("read 3 expected 404 (burned), got %d", rec.Code)
}
}
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
s := testServer(t)
h := s.routes()
+60 -11
View File
@@ -1,15 +1,17 @@
package api
import (
"palette/internal/store"
"encoding/json"
"fmt"
"io"
"net/http"
"palette/internal/store"
"strings"
"time"
"github.com/go-chi/chi/v5"
"palette/internal/web"
)
// CreateCan makes a can with N items (multipart form).
@@ -26,18 +28,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
writeErr(w, 400, "visibility must be public or unlisted")
writeErrCode(w, 400, "invalid_visibility", "visibility must be public or unlisted")
return
}
expiresIn := r.FormValue("expires_in")
password := r.FormValue("password")
customSlug := r.FormValue("custom_slug")
var expiresAt *int64
now := time.Now().Unix()
if expiresIn != "" {
d, err := time.ParseDuration(expiresIn)
if err != nil {
writeErr(w, 400, "invalid expires_in")
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
return
}
// #60/#48: clamp at the API boundary like the pastes API does -
// reject zero/negative and durations past the 1-year UI cap.
if !store.ValidExpiry(d) {
writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year")
return
}
t := now + int64(d.Seconds())
@@ -54,11 +63,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
}
canID := store.GenSlug(8)
err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
var slugPtr *string
if customSlug != "" {
slugPtr = &customSlug
}
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
if err != nil {
writeErr(w, 500, "db error")
switch err {
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
writeErrCode(w, 409, createErrCode(err), err.Error())
default:
writeErr(w, 500, "db error")
}
return
}
if slugPtr != nil {
canID = customSlug // #4: custom slug becomes the can id
}
// #4: remember the creating browser so /mine and viewer-scoped delete work
a.store.Exec(`UPDATE paste_cans SET viewer_id=? WHERE id=?`, currentViewerID(r), canID)
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
itemCount := 0
@@ -114,7 +137,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
if itemCount == 0 {
a.store.DeleteCan(canID)
writeErr(w, 400, "can needs at least one item (files or json_items)")
writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)")
return
}
@@ -123,6 +146,29 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
})
}
// handleDeleteCan soft-deletes a can (parity with paste deletion, #63):
// requires the vwr viewer cookie matching the can's viewer (cans carry no
// deletion token since they are built in the browser).
func (a *apiServer) handleDeleteCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
writeErr(w, 404, "can not found")
return
}
vid := currentViewerID(r)
if !(vid != "" && viewerSentCookie(r) && can.ViewerID.Valid &&
can.ViewerID.String != "" && can.ViewerID.String == vid) {
writeErr(w, 403, "deletion not authorized")
return
}
if _, err := a.store.SoftDeleteCan(can.ID); err != nil {
writeErr(w, 500, "db error")
return
}
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
func detectContentType(name string, content []byte) string {
lower := strings.ToLower(name)
switch {
@@ -143,8 +189,6 @@ func detectContentType(name string, content []byte) string {
return "text/plain"
}
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
@@ -207,7 +251,8 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not a can item")
return
}
// inherit can password protection
// inherit can password protection: password via header/query, or the
// same pw_<can> unlock cookie the can page sets (#4 cookie parity).
can, _ := a.store.GetCan(row.CanID.String)
if can != nil && can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
@@ -215,8 +260,12 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
pw = r.URL.Query().Get("password")
}
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
// fall back to the browser's unlock cookie for this can
c, cerr := r.Cookie("pw_" + can.ID)
if cerr != nil || c.Value != web.UnlockToken(can.ID) {
writeErr(w, 401, "password required")
return
}
}
}
// #34: same content-type guard as /raw — never serve active content types.
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"net/http/httptest"
"testing"
)
// #60: the cans API must clamp expires_in at the boundary exactly like the
// pastes API — reject zero/negative durations and anything over the 1-year
// UI cap, accept the exact boundaries.
func TestCreateCanExpiryBounds(t *testing.T) {
s := testServer(t)
h := s.routes()
cases := []struct {
expiresIn string
wantCode int
}{
{"-1h", 400}, // negative
{"-0s", 400}, // negative zero
{"0s", 400}, // zero
{"1ns", 400}, // positive but below the 1-minute floor
{"59s", 400}, // just under the floor
{"1m", 201}, // exactly the floor
{"90s", 201}, // just over the floor
{"8760h", 201}, // exactly 1 year
{"8785h", 400}, // 1 year + 1 day: over the cap
{"87600h", 400}, // 10 years, the originally reported case
}
for _, c := range cases {
globalLimiter = newLimiter() // avoid create rate limit between cases
body, ct := multipartBody(t, map[string]string{
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
"expires_in": c.expiresIn,
}, "files", "pic.txt", "file data")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.wantCode {
t.Errorf("expires_in %q: got %d want %d (%s)",
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
}
}
}
+413
View File
@@ -0,0 +1,413 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"palette/internal/store"
)
// #4: cans appear in /api/public as normal rows with is_can=true.
func TestCanInPublicListing(t *testing.T) {
s := testServer(t)
h := s.routes()
// a can and a regular paste
body, ct := multipartBody(t, map[string]string{
"title": "Listed can",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"plain"}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("public: %d", rec.Code)
}
var got struct {
Total int `json:"total"`
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
Title any `json:"title"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Total != 2 {
t.Fatalf("expected total 2 (can + paste), got %d", got.Total)
}
foundCan := false
for _, it := range got.Items {
if it.ID == created.ID {
if !it.IsCan {
t.Fatalf("can row missing is_can flag")
}
foundCan = true
} else if it.IsCan {
t.Fatalf("plain paste flagged as can")
}
}
if !foundCan {
t.Fatalf("can not present in /api/public")
}
// #4: unlisted can must not be listed
body2, ct2 := multipartBody(t, map[string]string{
"title": "Hidden can",
"visibility": "unlisted",
"json_items": `[{"title":"b.txt","content":"BBB"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body2)
req.Header.Set("Content-Type", ct2)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("unlisted can create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Hidden can") {
t.Fatalf("unlisted can leaked into /api/public")
}
}
// #4: custom slug support for cans + conflict with existing paste slug.
func TestCanCustomSlug(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Slugged",
"custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can with slug: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID, URL string }
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID != "my-bundle" {
t.Fatalf("expected id my-bundle, got %q", created.ID)
}
// custom slug resolves on the can page route
req = httptest.NewRequest("GET", "/can/my-bundle", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Slugged") {
t.Fatalf("can page by slug: %d", rec.Code)
}
// duplicate slug rejected 409
body, ct = multipartBody(t, map[string]string{
"title": "Again", "custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("duplicate slug expected 409, got %d", rec.Code)
}
// reserved slug rejected
body, ct = multipartBody(t, map[string]string{
"title": "R", "custom_slug": "admin",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("reserved slug expected 409, got %d", rec.Code)
}
}
// #4: invalid expiry rejected on cans, mirroring paste behavior (#48).
func TestCanExpiryValidation(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Bad expiry", "expires_in": "30s",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400 for 30s expiry, got %d", rec.Code)
}
}
// #4: expired cans 404 on the page and API.
func TestCanExpiryAccess(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Dying", "expires_in": "1h",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// backdate expiry to force the expired path
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, created.ID)
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can API expected 404, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can page expected 404, got %d", rec.Code)
}
}
// #4: password-protected can page uses the same unlock flow as pastes.
func TestCanUnlockFlow(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Locked can", "password": "pw123",
"json_items": `[{"title":"secret.txt","content":"sec content"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// GET page without cookie -> unlock form
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "locked") {
t.Fatalf("expected unlock form, got %d", rec.Code)
}
// POST wrong password -> unlock form with error
fd := strings.NewReader("password=wrong")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "Wrong password") {
t.Fatalf("expected wrong-password message")
}
// POST correct password -> page renders, cookie set
fd = strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("unlocked can page missing item content")
}
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("unlock cookie not set")
}
// GET with cookie -> unlocked
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("cookie unlock failed: %d", rec.Code)
}
// a forged cookie value must not unlock
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("forged cookie unlocked the can")
}
}
// #4: can item access inherits the can unlock cookie (not just query param).
func TestCanItemCookieParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Cookie can", "password": "pw123",
"json_items": `[{"title":"s.txt","content":"sec"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// unlock via page to get the cookie
fd := strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("no unlock cookie")
}
// item id from API (with password query)
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"?password=pw123", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
itemID := can.Items[0].ID
// API item with the unlock cookie but no password -> 200
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item via cookie expected 200, got %d", rec.Code)
}
// forged cookie -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("forged cookie on item expected 401, got %d", rec.Code)
}
}
// #4: viewer-scoped soft delete of cans.
func TestCanDeleteParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Doomed", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// delete without matching viewer -> 403
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 403 {
t.Fatalf("unauthorized delete expected 403, got %d", rec.Code)
}
// creator's browser -> 200
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("creator delete expected 200, got %d", rec.Code)
}
// gone from API and page
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("deleted can expected 404, got %d", rec.Code)
}
// gone from listings
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Doomed") {
t.Fatalf("deleted can still listed")
}
}
// #4: cans appear in /api/mine for the creating browser.
func TestCanInMine(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Mine can", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/mine", nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var got struct {
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if len(got.Items) != 1 || !got.Items[0].IsCan {
t.Fatalf("can missing from /api/mine: %s", rec.Body.String())
}
}
// #4: sweep soft-deletes expired cans (parity with pastes).
func TestCanSweepExpired(t *testing.T) {
s := testServer(t)
s.store.CreateCan("sweepcan", "sweep", "", "public", nil, 1, nil, nil)
s.store.InsertCanItem("sweepcan", "a.txt", "AAA", "text/plain", nil, nil, nil, 1)
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, "sweepcan")
s.store.SweepExpired()
if can, _ := s.store.GetCan("sweepcan"); can != nil {
t.Fatalf("expired can survived sweep")
}
_ = store.GenSlug(4) // keep import if store pkg shrinks
}
+172
View File
@@ -0,0 +1,172 @@
package api
// Regression tests for #63: DELETE /api/pastes/{id} must require the
// deletion token (Authorization header or ?token= query param, constant-time
// compare). Without a token, or with a wrong token, the paste must survive
// and the response must be 403.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// createTestPaste creates a paste via the API and returns id + deletion token.
func createTestPaste(t *testing.T, h http.Handler) (string, string) {
t.Helper()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"delete me"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
DeletionToken string `json:"deletion_token"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" || created.DeletionToken == "" {
t.Fatalf("create response missing id/deletion_token: %s", rec.Body.String())
}
return created.ID, created.DeletionToken
}
func pasteExists(t *testing.T, h http.Handler, id string) bool {
t.Helper()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 200 {
return true
}
if rec.Code == 404 {
return false
}
t.Fatalf("get after delete: got %d", rec.Code)
return false
}
func TestDeleteWithoutTokenForbidden(t *testing.T) {
s := testServer(t)
h := s.routes()
id, _ := createTestPaste(t, h)
rec := doReq(t, h, "DELETE", "/api/pastes/"+id, "", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("delete without token: got %d want 403", rec.Code)
}
if !pasteExists(t, h, id) {
t.Fatal("paste was deleted without a token")
}
}
func TestDeleteWithWrongTokenForbidden(t *testing.T) {
s := testServer(t)
h := s.routes()
id, _ := createTestPaste(t, h)
// query param
rec := doReq(t, h, "DELETE", "/api/pastes/"+id+"?token=wrong-token", "", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("delete with wrong token (query): got %d want 403", rec.Code)
}
// header
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
req.Header.Set("Authorization", "Bearer wrong-token")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("delete with wrong token (header): got %d want 403", rec.Code)
}
if !pasteExists(t, h, id) {
t.Fatal("paste was deleted with a wrong token")
}
}
func TestDeleteWithCorrectToken(t *testing.T) {
s := testServer(t)
h := s.routes()
id, tok := createTestPaste(t, h)
// via Authorization header
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
req.Header.Set("Authorization", "Bearer "+tok)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("delete with correct token (header): got %d want 200: %s", rec.Code, rec.Body.String())
}
if pasteExists(t, h, id) {
t.Fatal("paste still exists after authorized delete")
}
// via query param
id, tok = createTestPaste(t, h)
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"?token="+tok, "", "")
if rec.Code != 200 {
t.Fatalf("delete with correct token (query): got %d want 200", rec.Code)
}
if pasteExists(t, h, id) {
t.Fatal("paste still exists after authorized delete (query)")
}
}
func TestDeleteByCreatorViewerCookieStillAllowed(t *testing.T) {
s := testServer(t)
h := s.routes()
// create from a specific browser
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"mine"}`))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator-abc"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
// creator browser deletes without a token: allowed (#37 /mine delete button)
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, "creator-abc", "")
if rec.Code != 200 {
t.Fatalf("creator delete: got %d want 200", rec.Code)
}
// a different browser is still forbidden
id, _ := createTestPaste(t, h)
rec = doReq(t, h, "DELETE", "/api/pastes/"+id, "someone-else", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("other browser delete: got %d want 403", rec.Code)
}
if !pasteExists(t, h, id) {
t.Fatal("paste deleted by unrelated browser")
}
}
func TestDeletionAuthorizationExtract(t *testing.T) {
mk := func(hdr, q string) *http.Request {
req := httptest.NewRequest("DELETE", "/api/pastes/x"+q, nil)
if hdr != "" {
req.Header.Set("Authorization", hdr)
}
return req
}
cases := []struct {
hdr, q, want string
}{
{"", "", ""},
{"Bearer tok", "", "tok"},
{"bearer tok", "", "tok"},
{"Token tok", "", "tok"},
{"tok", "", "tok"},
{"", "?token=q", "q"},
{"Bearer hdr", "?token=q", "hdr"}, // header wins
}
for _, c := range cases {
if got := deletionAuthorization(mk(c.hdr, c.q)); got != c.want {
t.Errorf("deletionAuthorization(hdr=%q q=%q) = %q want %q", c.hdr, c.q, got, c.want)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// #105: every validation path on create returns a machine-readable `code`
// alongside the human `error` message, so the new-page JS can map known
// codes to plain-language guidance.
func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) (status int, errMsg, code string) {
t.Helper()
var got struct {
Error string `json:"error"`
Code string `json:"code"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("bad json: %v (%s)", err, rec.Body.String())
}
return rec.Code, got.Error, got.Code
}
func postCreate(h http.Handler, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestErrorCodeContentEmpty(t *testing.T) {
h := testServer(t).routes()
_, _, code := decodeErr(t, postCreate(h, `{"content":" "}`))
if code != "content_empty" {
t.Fatalf("code = %q, want content_empty", code)
}
}
func TestErrorCodeContentTooLarge(t *testing.T) {
h := testServer(t).routes()
big := strings.Repeat("x", 6*1024*1024) // over the 5MB test cap
_, _, code := decodeErr(t, postCreate(h, `{"content":"`+big+`"}`))
if code != "content_too_large" {
t.Fatalf("code = %q, want content_too_large", code)
}
}
func TestErrorCodeSlugTaken(t *testing.T) {
h := testServer(t).routes()
if rec := postCreate(h, `{"content":"a","custom_slug":"taken-slug"}`); rec.Code != 201 {
t.Fatalf("seed create: %d %s", rec.Code, rec.Body.String())
}
_, _, code := decodeErr(t, postCreate(h, `{"content":"b","custom_slug":"taken-slug"}`))
if code != "slug_taken" {
t.Fatalf("code = %q, want slug_taken", code)
}
}
func TestErrorCodeSlugInvalid(t *testing.T) {
h := testServer(t).routes()
_, _, code := decodeErr(t, postCreate(h, `{"content":"a","custom_slug":"bad slug!"}`))
if code != "slug_invalid" {
t.Fatalf("code = %q, want slug_invalid", code)
}
}
func TestErrorCodeExpiryInvalid(t *testing.T) {
h := testServer(t).routes()
_, _, code := decodeErr(t, postCreate(h, `{"content":"a","expires_in":"2s"}`))
if code != "expiry_invalid" {
t.Fatalf("code = %q, want expiry_invalid", code)
}
}
func TestErrorCodeRateLimited(t *testing.T) {
h := testServer(t).routes()
var code string
for i := 0; i < 20; i++ {
_, _, code = decodeErr(t, postCreate(h, `{"content":"a"}`))
if code == "rate_limited" {
return
}
}
t.Fatalf("never got rate_limited, last code = %q", code)
}
func TestErrorCodeCanValidation(t *testing.T) {
h := testServer(t).routes()
post := func(fields map[string]string) *httptest.ResponseRecorder {
var b strings.Builder
for k, v := range fields {
b.WriteString("--B\r\nContent-Disposition: form-data; name=\"" + k + "\"\r\n\r\n" + v + "\r\n")
}
b.WriteString("--B--\r\n")
req := httptest.NewRequest("POST", "/api/pastes/can", strings.NewReader(b.String()))
req.Header.Set("Content-Type", "multipart/form-data; boundary=B")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// bad visibility
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "visibility": "nope"})); code != "invalid_visibility" {
t.Fatalf("visibility code = %q, want invalid_visibility", code)
}
// invalid expiry
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "expires_in": "1s"})); code != "expiry_invalid" {
t.Fatalf("expiry code = %q, want expiry_invalid", code)
}
// slug taken on can create
if rec := postCreate(h, `{"content":"a","custom_slug":"can-slug"}`); rec.Code != 201 {
t.Fatalf("seed create: %d", rec.Code)
}
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "custom_slug": "can-slug", "json_items": `[{"title":"a","content":"b"}]`})); code != "slug_taken" {
t.Fatalf("can slug code = %q, want slug_taken", code)
}
}
+198
View File
@@ -0,0 +1,198 @@
package api
// Regression tests for #68 input-validation gaps: negative/oversized content
// lengths (413), limit=0 → default page size, unchecked query params
// (negative offset), and negative burn_after_reads.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func createPasteRaw(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// A request whose decoded content exceeds the admin-tunable cap is rejected
// with 413 and a clear message (content under the body cap, over the
// content cap).
func TestCreatePasteContentOverCap413(t *testing.T) {
s := testServer(t)
h := s.routes()
// content over MaxContentBytes (5MiB) but under body cap (+4KiB): send
// just over the content cap so the per-field check fires first.
content := strings.Repeat("a", 5*1024*1024+10)
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s"}`, content))
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "content exceeds max") {
t.Fatalf("unclear error message: %s", rec.Body.String())
}
}
// A request whose entire body is larger than the server-side body cap is cut
// off by http.MaxBytesReader and answered with 413, not decoded into memory
// (#68: previously a giant body was fully buffered, then rejected only at
// the per-field check — actually the decode happened before any check).
func TestCreatePasteBodyOverCap413(t *testing.T) {
s := testServer(t)
// shrink the content cap so the body cap is small too
ss := s.settings.get()
ss.MaxContentBytes = 64 * 1024
if err := s.settings.set(ss); err != nil {
t.Fatal(err)
}
h := s.routes()
content := strings.Repeat("a", 200*1024) // 200KiB > 64KiB+4KiB body cap
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s","title":"x"}`, content))
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
}
}
// Negative content lengths cannot be expressed via JSON, but a negative
// expires-style numeric payload must not crash; more importantly the
// burn_after_reads field: negative values are rejected with a clear message.
func TestCreatePasteNegativeBurnAfterReads(t *testing.T) {
s := testServer(t)
h := s.routes()
rec := createPasteRaw(t, h, `{"content":"hi","burn_after_reads":-5}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "burn_after_reads") {
t.Fatalf("unclear error message: %s", rec.Body.String())
}
// 0 and positive values still work (0 = default single read, per #49)
rec = createPasteRaw(t, h, `{"content":"hi","burn_after_reads":0,"burn_after_read":true}`)
if rec.Code != http.StatusCreated {
t.Fatalf("zero burn_after_reads: got %d want 201: %s", rec.Code, rec.Body.String())
}
}
// limit=0 on list endpoints returns the default page size (existing clamp
// treats <=0 as default; #68 asks this be explicit and tested).
func TestListLimitZeroUsesDefault(t *testing.T) {
s := testServer(t)
h := s.routes()
// seed 3 public pastes
for i := 0; i < 3; i++ {
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
if rec.Code != 201 {
t.Fatalf("seed: got %d: %s", rec.Code, rec.Body.String())
}
}
for _, q := range []string{"/api/public?limit=0", "/api/public"} {
req := httptest.NewRequest("GET", q, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("%s: got %d", q, rec.Code)
}
var got struct {
Limit int `json:"limit"`
Total int `json:"total"`
Items []struct{ ID string } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Limit != 25 || len(got.Items) != 3 {
t.Fatalf("%s: limit=%d items=%d, want limit 25 and all 3 items", q, got.Limit, len(got.Items))
}
}
}
// Huge limit values are clamped to the max page size (already the behavior;
// regression-tested here per #68 "validate unchecked params").
func TestListLimitHugeClamped(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/api/public?limit=999999999", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var got struct {
Limit int `json:"limit"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Limit != 25 {
t.Fatalf("limit=%d, want clamped to 25", got.Limit)
}
}
// Negative offset previously passed through unchecked to SQL (harmless in
// SQLite, but invalid); it must be clamped to 0 (#68).
func TestListNegativeOffsetClamped(t *testing.T) {
s := testServer(t)
h := s.routes()
for i := 0; i < 2; i++ {
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
if rec.Code != 201 {
t.Fatalf("seed: got %d", rec.Code)
}
}
req := httptest.NewRequest("GET", "/api/public?offset=-999", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("got %d", rec.Code)
}
var got struct {
Offset int `json:"offset"`
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Offset != 0 || got.Total != 2 {
t.Fatalf("offset=%d total=%d, want offset 0 and total 2", got.Offset, got.Total)
}
// /api/mine too (needs the viewer cookie)
req = httptest.NewRequest("GET", "/api/mine?offset=-5", nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "offclamp"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var mine struct {
Offset int `json:"offset"`
}
json.Unmarshal(rec.Body.Bytes(), &mine)
if mine.Offset != 0 {
t.Fatalf("mine offset=%d, want 0", mine.Offset)
}
}
// Non-numeric limit/offset fall back to defaults instead of 500s.
func TestListGarbageParams(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/api/public?limit=abc&offset=xyz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("got %d", rec.Code)
}
var got struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Limit != 25 || got.Offset != 0 {
t.Fatalf("limit=%d offset=%d, want 25/0", got.Limit, got.Offset)
}
}
@@ -0,0 +1,92 @@
package api
// #81: ALL password verification attempts (GET query param, header, POST
// form) must go through the per-IP unlock limiter. Regression: N wrong
// passwords via GET ?password= must eventually yield 429.
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func createPasswordPaste(t *testing.T, s *apiServer, pw string) string {
t.Helper()
h := s.routes()
body := `{"content":"secret","password":"` + pw + `"}`
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
return created.ID
}
// TestRateLimitGetPasswordQuery: repeated wrong passwords via GET
// ?password= must eventually return 429 (unlock limiter: burst 5).
func TestRateLimitGetPasswordQuery(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createPasswordPaste(t, s, "hunter2")
var saw429 bool
// more attempts than the unlock burst (5)
for i := 0; i < 10; i++ {
req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=wrong"+string(rune('a'+i)), nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 429 {
saw429 = true
break
}
if rec.Code != 401 {
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after repeated wrong ?password= attempts, never got one")
}
}
// TestRateLimitGetPasswordHeader: same guarantee for the X-Paste-Password header path.
func TestRateLimitGetPasswordHeader(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createPasswordPaste(t, s, "hunter2")
var saw429 bool
for i := 0; i < 10; i++ {
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 429 {
saw429 = true
break
}
if rec.Code != 401 {
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after repeated wrong header password attempts, never got one")
}
}
// TestRateLimitGetPasswordCorrectStillAllowed: a correct password must still
// work within the burst (the limiter gates attempts, not correctness).
func TestRateLimitGetPasswordCorrectStillAllowed(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createPasswordPaste(t, s, "hunter2")
req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=hunter2", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200 for correct password within burst, got %d", rec.Code)
}
}
+152
View File
@@ -0,0 +1,152 @@
package api
// Regression tests for #86: title and language are bounded at create time.
// Titles over 200 chars are truncated; language must match
// ^[a-zA-Z0-9+#-]{1,40}$ or the create is rejected with a clear 400.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// pasteMeta fetches a created paste's stored metadata via the API.
func pasteMeta(t *testing.T, h http.Handler, id string) map[string]any {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/pastes/"+id, nil))
if rec.Code != 200 {
t.Fatalf("get paste %s: got %d: %s", id, rec.Code, rec.Body.String())
}
var m map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatal(err)
}
return m
}
// A 5000-char title is truncated to 200 characters at create time (#86).
func TestCreatePasteTitleTruncated(t *testing.T) {
s := testServer(t)
h := s.routes()
title := strings.Repeat("t", 5000)
body, _ := json.Marshal(map[string]any{"content": "hi", "title": title})
rec := createPasteRaw(t, h, string(body))
if rec.Code != http.StatusCreated {
t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String())
}
var resp struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
meta := pasteMeta(t, h, resp.ID)
got, _ := meta["title"].(string)
if got != strings.Repeat("t", 200) {
t.Fatalf("title not truncated to 200 chars: len=%d", len(got))
}
}
// A title within the 200-char bound is stored verbatim (minus surrounding
// whitespace, which is trimmed).
func TestCreatePasteTitleWithinBoundKept(t *testing.T) {
s := testServer(t)
h := s.routes()
title := " " + strings.Repeat("x", 200) + " "
body, _ := json.Marshal(map[string]any{"content": "hi", "title": title})
rec := createPasteRaw(t, h, string(body))
if rec.Code != http.StatusCreated {
t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String())
}
var resp struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
meta := pasteMeta(t, h, resp.ID)
if got, _ := meta["title"].(string); got != strings.Repeat("x", 200) {
t.Fatalf("title changed unexpectedly: len=%d", len(got))
}
}
// A language longer than 40 chars is rejected with a clear 400 (#86).
func TestCreatePasteLanguageTooLong400(t *testing.T) {
s := testServer(t)
h := s.routes()
body, _ := json.Marshal(map[string]any{"content": "hi", "language": strings.Repeat("a", 41)})
rec := createPasteRaw(t, h, string(body))
if rec.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "language") {
t.Fatalf("unclear error message: %s", rec.Body.String())
}
}
// Language strings outside ^[a-zA-Z0-9+#-]{1,40}$ are rejected with 400.
func TestCreatePasteLanguageBadFormat400(t *testing.T) {
s := testServer(t)
h := s.routes()
for _, bad := range []string{
"<img src=x onerror=alert(1)>",
"java script",
"c++ extra!",
"py_thon",
"go.lang",
} {
body, _ := json.Marshal(map[string]any{"content": "hi", "language": bad})
rec := createPasteRaw(t, h, string(body))
if rec.Code != http.StatusBadRequest {
t.Errorf("language %q: got %d want 400: %s", bad, rec.Code, rec.Body.String())
continue
}
if !strings.Contains(rec.Body.String(), "language must match") {
t.Errorf("language %q: unclear error: %s", bad, rec.Body.String())
}
}
}
// Valid languages (letters, digits, #, +, -) within 40 chars are accepted.
func TestCreatePasteLanguageValidAccepted(t *testing.T) {
for _, ok := range []string{"go", "c#", "f#", "c++", "objective-c", "ECMAScript-2023", strings.Repeat("a", 40)} {
s2 := testServer(t) // fresh rate limiter per case
h2 := s2.routes()
body, _ := json.Marshal(map[string]any{"content": "hi", "language": ok})
rec := createPasteRaw(t, h2, string(body))
if rec.Code != http.StatusCreated {
t.Errorf("language %q: got %d want 201: %s", ok, rec.Code, rec.Body.String())
}
}
}
// An absent or blank language still creates fine and stores NULL, and a
// blank title is stored NULL rather than an empty string.
func TestCreatePasteBlankMetadataOK(t *testing.T) {
s := testServer(t)
h := s.routes()
for _, body := range []string{
`{"content":"hi"}`,
`{"content":"hi","language":"","title":" "}`,
} {
rec := createPasteRaw(t, h, body)
if rec.Code != http.StatusCreated {
t.Fatalf("body %s: got %d want 201: %s", body, rec.Code, rec.Body.String())
}
var resp struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
meta := pasteMeta(t, h, resp.ID)
if lang, ok := meta["language"]; ok && lang != nil && lang != "" {
t.Fatalf("body %s: language not null: %v", body, lang)
}
if title, ok := meta["title"]; ok && title != nil && title != "" {
t.Fatalf("body %s: title not null: %v", body, title)
}
}
}
+58 -6
View File
@@ -42,7 +42,9 @@ func TestCreateAndGetPaste(t *testing.T) {
if rec.Code != 201 {
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
}
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if len(created.ID) != 6 {
t.Fatalf("unexpected id: %q", created.ID)
@@ -73,7 +75,9 @@ func TestPasswordProtection(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
// without password -> 401
@@ -133,10 +137,14 @@ func TestSoftDelete(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
DeletionToken string `json:"deletion_token"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil)
req.Header.Set("Authorization", "Bearer "+created.DeletionToken)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
@@ -166,7 +174,7 @@ func TestListPublicExcludesUnlisted(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var resp struct {
Total int `json:"total"`
Total int `json:"total"`
Items []map[string]any `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
@@ -175,6 +183,46 @@ func TestListPublicExcludesUnlisted(t *testing.T) {
}
}
func TestListPublicExcludesPasswordAndUnlisted(t *testing.T) {
s := testServer(t)
h := s.routes()
bodies := []string{
`{"content":"open","visibility":"public"}`,
`{"content":"locked","visibility":"public","password":"hunter2"}`,
`{"content":"hidden","visibility":"unlisted"}`,
}
for _, body := range bodies {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create %s: got %d", body, rec.Code)
}
}
req := httptest.NewRequest("GET", "/api/public", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("list public: got %d", rec.Code)
}
var resp struct {
Total int `json:"total"`
Items []map[string]any `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 || len(resp.Items) != 1 {
t.Fatalf("expected only the 1 public paste, got total=%d items=%d", resp.Total, len(resp.Items))
}
// password-protected and unlisted pastes must not appear (no metadata leak)
for _, secret := range []string{"hunter2", "locked", "hidden"} {
if strings.Contains(rec.Body.String(), secret) {
t.Fatalf("leaked %q in /api/public response", secret)
}
}
}
func TestSweepSoftDeletesAfterGrace(t *testing.T) {
s := testServer(t)
h := s.routes()
@@ -182,7 +230,9 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
s.store.SoftDelete(created.ID)
@@ -218,7 +268,9 @@ func TestRawEndpoint(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("GET", "/raw/"+created.ID, nil)
+97
View File
@@ -0,0 +1,97 @@
package api
// #83 regression tests: `public` boolean in the create payload must map to
// visibility (false -> unlisted, true -> public); string `visibility` still works.
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func createPasteBody(t *testing.T, h *apiServer, body string) map[string]any {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.routes().ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad json: %v", err)
}
return resp
}
func getVis(t *testing.T, h *apiServer, id string) string {
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec := httptest.NewRecorder()
h.routes().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("get %s: got %d", id, rec.Code)
}
var resp struct {
Visibility string `json:"visibility"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad json: %v", err)
}
return resp.Visibility
}
// TestPublicBooleanFalseMapsToUnlisted: {"public": false} must create an unlisted paste.
func TestPublicBooleanFalseMapsToUnlisted(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","public":false}`)
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
t.Fatalf("public:false -> got visibility %q, want unlisted", v)
}
}
// TestPublicBooleanTrueMapsToPublic: {"public": true} must create a public paste.
func TestPublicBooleanTrueMapsToPublic(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","public":true}`)
if v := getVis(t, s, resp["id"].(string)); v != "public" {
t.Fatalf("public:true -> got visibility %q, want public", v)
}
}
// TestPublicBooleanOverridesString: boolean wins when both fields are sent.
func TestPublicBooleanOverridesString(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","visibility":"public","public":false}`)
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
t.Fatalf("boolean override -> got %q, want unlisted", v)
}
}
// TestVisibilityStringStillWorks: existing string contract unchanged.
func TestVisibilityStringStillWorks(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","visibility":"unlisted"}`)
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
t.Fatalf("string field -> got %q, want unlisted", v)
}
resp = createPasteBody(t, s, `{"content":"y","visibility":"public"}`)
if v := getVis(t, s, resp["id"].(string)); v != "public" {
t.Fatalf("string field -> got %q, want public", v)
}
}
// TestPublicListExcludesPublicFalse: {"public":false} pastes stay out of /api/public.
func TestPublicListExcludesPublicFalse(t *testing.T) {
s := testServer(t)
createPasteBody(t, s, `{"content":"hidden","public":false}`)
req := httptest.NewRequest("GET", "/api/public", nil)
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, req)
var resp struct {
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 0 {
t.Fatalf("public:false paste leaked into /api/public: total=%d", resp.Total)
}
}
+28 -2
View File
@@ -48,8 +48,28 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
return true
}
// clientIP extracts the request IP (no reverse proxy header by default).
// 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]
@@ -84,10 +104,16 @@ func rateLimitUnlock(id string, r *http.Request) bool {
return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
}
// rateLimitAdmin: 5 attempts per minute per IP on the admin key check (#66),
// same pattern as the unlock limiter (#34).
func rateLimitAdmin(r *http.Request) bool {
return globalLimiter.allow("admin:"+clientIP(r), 5.0/60.0, 5)
}
// writeRateLimited responds 429 with Retry-After based on refill rate.
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
writeErr(w, 429, "rate limit exceeded")
writeErrCode(w, 429, "rate_limited", "rate limit exceeded")
}
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
+85
View File
@@ -0,0 +1,85 @@
package api
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
// (appended by the trusted Traefik proxy), never the raw/leftmost header
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
// limit or rotate buckets.
import (
"bytes"
"net/http/httptest"
"testing"
)
func TestClientIPTakesRightmostXFF(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
if got := clientIP(r); got != "203.0.113.9" {
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
}
}
func TestClientIPXRealIPFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000"
r.Header.Set("X-Real-Ip", "203.0.113.10")
if got := clientIP(r); got != "203.0.113.10" {
t.Fatalf("clientIP = %q, want 203.0.113.10", got)
}
}
func TestClientIPDirectFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "198.51.100.5:51000"
if got := clientIP(r); got != "198.51.100.5" {
t.Fatalf("clientIP = %q, want 198.51.100.5", got)
}
}
// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
// leftmost XFF entry stays limited on their real (rightmost) IP.
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for i := 0; i < 5; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
// each request spoofs a DIFFERENT leftmost entry
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("req %d: want 201, got %d", i, rr.Code)
}
}
// 6th request, still the same real IP, new spoofed prefix: must 429
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
}
}
func spoofN(i int) string {
return "1.2.3." + string(rune('0'+i))
}
// Distinct real IPs must still get distinct buckets (no over-limiting).
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
}
}
}
+249 -40
View File
@@ -4,8 +4,11 @@ package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
@@ -15,8 +18,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"database/sql"
langpkg "palette/internal/lang"
"palette/internal/store"
"palette/internal/web"
)
@@ -29,11 +31,11 @@ type Config struct {
}
type apiServer struct {
store *store.Store
cfg Config
ui *web.UI
settings *settingsStore
adminKey string
store *store.Store
cfg Config
ui *web.UI
settings *settingsStore
adminKey string
}
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
@@ -50,6 +52,30 @@ func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// writeErrCode emits a JSON error with a machine-readable code (#105): the
// new-page JS maps known codes to plain-language messages, API consumers get
// the stable `code` field alongside the human `error` text.
func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": msg, "code": code})
}
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
// code (#105).
func createErrCode(err error) string {
switch {
case errors.Is(err, store.ErrSlugTaken):
return "slug_taken"
case errors.Is(err, store.ErrReservedSlug):
return "slug_reserved"
case errors.Is(err, store.ErrInvalidSlug):
return "slug_invalid"
case strings.Contains(err.Error(), "expires_in"):
return "expiry_invalid"
default:
return "invalid_request"
}
}
// Routes returns the HTTP handler for the server.
func (a *apiServer) Routes() http.Handler {
return a.routes()
@@ -59,7 +85,9 @@ func (a *apiServer) routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(a.limitRequestBody) // #68: hard server-side body cap -> 413
r.Use(viewerCookieMiddleware)
r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
// admin (#40): HTML page is open (key entry via form); API is key-guarded
r.Get("/admin", a.ui.Handlers().HandleAdminPage)
@@ -77,11 +105,13 @@ func (a *apiServer) routes() http.Handler {
r.Post("/guess-language", a.handleGuessLang)
r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan)
r.Delete("/cans/{id}", a.handleDeleteCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
})
// can page
// can page (#4): GET renders, POST unlocks (same flow as pastes)
r.Get("/can/{id}", a.handleCanPage)
r.Post("/can/{id}", a.handleCanPage)
// raw
r.Get("/raw/{id}", a.handleRaw)
@@ -152,16 +182,47 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
}
var p store.Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
return
}
writeErr(w, 400, "invalid json body")
return
}
if strings.TrimSpace(p.Content) == "" {
writeErr(w, 400, "content is required")
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
if status == http.StatusRequestEntityTooLarge {
writeErrCode(w, status, "content_too_large", msg)
} else {
writeErrCode(w, status, "content_empty", msg)
}
return
}
if int64(len(p.Content)) > s.MaxContentBytes { // #40: admin-tunable
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", s.MaxContentBytes))
return
// #86: bound free-form metadata at create time
if p.Title != nil {
t, err := checkTitle(*p.Title)
if err != nil {
writeErr(w, 400, err.Error())
return
}
p.Title = &t
}
if p.Language != nil {
l, err := checkLanguage(*p.Language)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if l == "" {
p.Language = nil
} else {
p.Language = &l
}
}
if p.BurnAfterReads != nil { // #68: reject negative read budgets
if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
writeErr(w, 400, err.Error())
return
}
}
// #40: admin-configurable default expiry
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
@@ -171,7 +232,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
p.ViewerID = currentViewerID(r)
created, err := a.store.CreatePaste(&p)
if err != nil {
writeErr(w, 400, err.Error())
writeErrCode(w, 400, createErrCode(err), err.Error())
return
}
writeJSON(w, 201, map[string]any{
@@ -206,6 +267,13 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
return
}
if row.PasswordHash.Valid {
// #81: every password verification (header, query param, or empty)
// goes through the same per-IP+paste unlock limiter as the POST form
// path, so brute-force via GET ?password= or X-Paste-Password gets 429.
if !rateLimitUnlock(row.ID, r) {
writeRateLimited(w, 60)
return
}
// require password via header or query
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
@@ -216,7 +284,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
return
}
}
rem, _ := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow()) // #49 (also covers legacy burn)
// #58: only the reader that wins the atomic burn claim may see content.
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
if !admitted {
writeErr(w, 404, "paste not found")
return
}
writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
@@ -232,21 +305,52 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "paste not found")
return
}
// viewer-cookie delete enforcement (#37): only the browser that created
// the paste (matching vwr) may delete it via this endpoint. Requests with
// no client-sent vwr cookie (plain API clients) are unaffected.
vid := currentViewerID(r)
if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid {
writeErr(w, 403, "not your paste")
// #63: deletion requires authorization. Either the deletion token issued
// at create time (Authorization header or ?token= query param, matching
// the create response's "deletion_token" field), or the creator browser
// itself (client-sent vwr cookie matching the paste's viewer, #37).
if !a.deletionAuthorized(r, row) {
writeErr(w, 403, "deletion token required")
return
}
if err := a.store.SoftDelete(row.ID); err != nil {
if _, err := a.store.SoftDelete(row.ID); err != nil {
writeErr(w, 500, "db error")
return
}
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
// deletionAuthorization extracts the deletion token from the request: the
// Authorization header ("Bearer <t>", "Token <t>", or a bare token) or the
// token query parameter. Returns "" when absent.
func deletionAuthorization(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
for _, prefix := range []string{"Bearer ", "Token "} {
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
return strings.TrimSpace(h[len(prefix):])
}
}
return strings.TrimSpace(h)
}
return r.URL.Query().Get("token")
}
// deletionAuthorized reports whether the request may soft-delete the paste:
// a valid constant-time-matched deletion token, or the creator browser's
// viewer cookie (#37). Plain API clients with no token get false.
func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) bool {
if tok := deletionAuthorization(r); tok != "" {
return row.DeletionToken.Valid && row.DeletionToken.String != "" &&
store.DeletionTokenEqual(row.DeletionToken.String, tok)
}
// viewer-cookie delete enforcement (#37): only the browser that created
// the paste (matching vwr) may delete it via this endpoint. Requests with
// no client-sent vwr cookie (plain API clients) are unaffected.
vid := currentViewerID(r)
return vid != "" && viewerSentCookie(r) && row.ViewerID.Valid &&
row.ViewerID.String != "" && row.ViewerID.String == vid
}
// handleListMine serves /api/mine: pastes created from this browser (#37).
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
vid := currentViewerID(r)
@@ -254,11 +358,8 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit := parseLimit(r, 50, 100)
offset := parseOffset(r)
rows, total, err := a.store.ListMine(vid, limit, offset)
if err != nil {
writeErr(w, 500, "db error")
@@ -271,17 +372,15 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
"is_can": row.IsCan,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
}
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 25
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit := parseLimit(r, 25, 100)
offset := parseOffset(r)
rows, total, err := a.store.ListPublic(limit, offset)
if err != nil {
writeErr(w, 500, "db error")
@@ -294,6 +393,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug),
"is_can": row.IsCan,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -319,8 +419,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
return
}
// #49 decision: raw reads count against the read budget too, with the
// same per-viewer dedupe window as page views.
a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
// same per-viewer dedupe window as page views. #58: a reader that loses
// the burn claim must not receive the content.
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
if !admitted {
http.Error(w, "not found", 404)
return
}
// #34: content_type is attacker-controlled via the create API. Serving it
// verbatim let a paste be stored with text/html (or image/svg+xml) and
// render as active content on this origin when fetched from /raw —
@@ -332,7 +437,7 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
a.store.IncrementViews(row.ID)
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
w.Write([]byte(row.Content))
}
@@ -361,13 +466,117 @@ func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
items, _ := a.store.ListCanItems(can.ID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
http.NotFound(w, r)
return
}
// #4: password-protected cans go through the same unlock flow as pastes:
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
// inherit the protection (handleCanItem checks the same cookie).
if can.PasswordHash.Valid {
h := a.webHandlers()
if r.Method == http.MethodPost {
if !rateLimitUnlock(can.ID, r) {
h.WriteRateLimited(w, 60)
return
}
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
http.SetCookie(w, &http.Cookie{
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
a.renderCan(w, can)
return
}
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": true,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
c, err := r.Cookie("pw_" + can.ID)
if err != nil || c.Value != web.UnlockToken(can.ID) {
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": false,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
}
a.renderCan(w, can)
}
// renderCan renders the can view page: title/description and items as cards.
// Text items expand inline; files link to download.
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
h := a.webHandlers()
items, err := a.store.ListCanItems(can.ID)
if err != nil {
http.Error(w, "db error", 500)
return
}
type canItem struct {
ID string
Title string
ContentType string
Size string
IsFile bool
Content string
ContentHTML template.HTML
Language string
}
cards := make([]canItem, 0, len(items))
totalSize := 0
for _, it := range items {
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
totalSize += len(it.Content)
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
ci := canItem{
ID: it.ID,
Title: nullStrOr(it.Title, it.ID),
ContentType: it.ContentType,
Size: web.HumanSize(len(it.Content)),
IsFile: isFile,
Language: it.Language.String,
}
if !isFile {
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
}
cards = append(cards, ci)
}
h.RenderPage(w, "can.html", map[string]any{
"Page": "can",
"ID": can.ID,
"Title": nullStrOr(can.Title, "Untitled can"),
"Description": can.Description.String,
"HasDescription": can.Description.Valid && can.Description.String != "",
"HasPassword": can.PasswordHash.Valid,
"Items": cards,
"ItemCount": len(cards),
"SizeHuman": web.HumanSize(totalSize),
"CreatedAgo": web.AgoString(can.CreatedAt),
"CreatedAtUnix": can.CreatedAt,
"ExpiresAt": can.ExpiresAt.Valid,
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
})
}
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
func expiryStringIfValid(ns sql.NullInt64) string {
if !ns.Valid {
return ""
}
remaining := ns.Int64 - time.Now().Unix()
s := remaining
switch {
case s < 3600:
return fmt.Sprintf("%dm", s/60)
case s < 86400:
return fmt.Sprintf("%dh", s/3600)
default:
return fmt.Sprintf("%dd", s/86400)
}
fmt.Fprintf(w, "</ul></body></html>")
}
func templateEsc(s string) string {
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
)
// #68 input-validation helpers. Paste/can payloads are size-capped and list
// endpoints get a single place where limit/offset are parsed and clamped.
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
// admin-tunable content cap plus headroom for JSON field overhead, floored
// at 64KiB so a tiny admin-configured cap can't break small requests.
func (a *apiServer) maxRequestBody() int64 {
s := a.settings.get()
max := s.MaxContentBytes + 4096
if max < 64*1024 {
max = 64 * 1024
}
return max
}
// limitRequestBody wraps the request body with http.MaxBytesReader so
// oversized payloads are cut off server-side instead of being fully decoded
// into memory before the per-field size check runs (#68). A read over the
// cap surfaces as *http.MaxBytesError, which handlers map to 413.
func (a *apiServer) limitRequestBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, a.maxRequestBody())
}
next.ServeHTTP(w, r)
})
}
// writeBodyTooLarge emits the 413 response for a body rejected by
// MaxBytesReader.
func writeBodyTooLarge(w http.ResponseWriter) {
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
}
// isBodyTooLarge reports whether err came from http.MaxBytesReader.
func isBodyTooLarge(err error) bool {
var mbe *http.MaxBytesError
return errors.As(err, &mbe)
}
// checkContent validates paste content: rejects whitespace-only content
// (400) and content over the byte cap (413). Returns (0, "") when valid.
func checkContent(content string, maxBytes int64) (int, string) {
if strings.TrimSpace(content) == "" {
return http.StatusBadRequest, "content is required"
}
if int64(len(content)) > maxBytes { // #40/#68: admin-tunable cap
return http.StatusRequestEntityTooLarge,
fmt.Sprintf("content exceeds max %d bytes", maxBytes)
}
return 0, ""
}
// parseLimit clamps the ?limit query param: missing/non-numeric/zero/negative
// or over-max values fall back to def. Zero intentionally maps to the default
// page size, matching the pre-existing `<= 0` clamp (#68).
func parseLimit(r *http.Request, def, max int) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 || n > max {
return def
}
return n
}
// parseOffset clamps the ?offset query param: missing/non-numeric or negative
// values become 0 (#68: negative offsets previously passed through to SQL).
func parseOffset(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("offset"))
if err != nil || n < 0 {
return 0
}
return n
}
// parseBurnAfterReads validates the burn_after_reads field (#68): negative
// values are rejected; zero/absent mean the default single read.
func parseBurnAfterReads(n int) error {
if n < 0 {
return errors.New("burn_after_reads must be a positive number")
}
return nil
}
// #86: bounds for free-form metadata fields on create.
const (
maxTitleLen = 200
maxLanguageLen = 40
)
// languageRe restricts language to identifiers like go, c#, f#, c++, objc.
var languageRe = regexp.MustCompile(`^[a-zA-Z0-9+#-]{1,40}$`)
// checkTitle validates the paste title (#86): over-max titles are truncated
// to 200 characters so a bloated listing entry can't be stored; whitespace
// is trimmed first.
func checkTitle(title string) (string, error) {
title = strings.TrimSpace(title)
if len(title) > maxTitleLen {
return truncateRunes(title, maxTitleLen), nil
}
return title, nil
}
// checkLanguage validates the language field (#86): optional, max 40 chars,
// and must match ^[a-zA-Z0-9+#-]{1,40}$. Returns "" for absent/blank values.
// Anything else malformed is a 400.
func checkLanguage(lang string) (string, error) {
lang = strings.TrimSpace(lang)
if lang == "" {
return "", nil
}
if len(lang) > maxLanguageLen || !languageRe.MatchString(lang) {
return "", fmt.Errorf("language must match ^[a-zA-Z0-9+#-]{1,40}$ (max %d chars)", maxLanguageLen)
}
return lang, nil
}
// truncateRunes cuts s to at most max runes, keeping the prefix intact.
func truncateRunes(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"palette/internal/store"
)
// #95: HTML paste views dedupe per viewer (vwr cookie) within the burn viewer
// window, reusing the paste_views table. Repeated views by the same viewer do
// not inflate view_count; a different viewer counts.
func TestHTMLViewsDedupedPerViewer(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vd"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
view := func(vwr string) {
req := httptest.NewRequest("GET", "/"+id, nil)
if vwr != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: vwr})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("view: got %d", rec.Code)
}
}
count := func() string {
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Body.String()
}
// same viewer (same cookie) renders twice: still 1 view
view("v-a")
view("v-a")
if got := count(); !strings.Contains(got, `"view_count":1`) {
t.Fatalf("expected view_count 1 after duplicate viewer views, got: %s", got)
}
// a different viewer counts
view("v-b")
if got := count(); !strings.Contains(got, `"view_count":2`) {
t.Fatalf("expected view_count 2 after second viewer, got: %s", got)
}
// viewer without cookie counts every time (plain API clients, #49 decision)
view("")
view("")
if got := count(); !strings.Contains(got, `"view_count":4`) {
t.Fatalf("expected view_count 4 after uncoookied views, got: %s", got)
}
}
// #95: raw views always count, even from a cookie-holding viewer (#49 decision).
func TestRawViewsAlwaysCount(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"rv"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
for i := 0; i < 3; i++ {
req := httptest.NewRequest("GET", "/raw/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "same-viewer"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("raw view: got %d", rec.Code)
}
}
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Body.String(); !strings.Contains(got, `"view_count":3`) {
t.Fatalf("expected view_count 3 after 3 raw views by one viewer, got: %s", got)
}
}
// #95: after the dedupe window passes, the same viewer counts again.
func TestViewDedupeWindowExpires(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"we"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
view := func() {
req := httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-w"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("view: got %d", rec.Code)
}
}
view()
view()
// jump past the 15-minute window
realNow := store.TimeNow
store.TimeNow = func() time.Time { return realNow().Add(16 * time.Minute) }
defer func() { store.TimeNow = realNow }()
view()
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Body.String(); !strings.Contains(got, `"view_count":2`) {
t.Fatalf("expected view_count 2 after window expiry, got: %s", got)
}
}
+35 -10
View File
@@ -17,23 +17,34 @@ func genDeletionToken() string {
// TimeNow is overridable in tests to inject the clock.
var TimeNow = time.Now
// registerRead applies the burn-after-read budget for one view (#49).
// RegisterRead applies the burn-after-read budget for one view (#49).
// For pastes with reads_limit set: the viewer's paste_views row is checked;
// a view within the burn viewer window of the viewer's last view is deduped
// (count=false). Otherwise reads_used is incremented, and the paste is
// soft-deleted (burned) once reads_used reaches reads_limit. Viewers without
// a cookie (plain API clients) count as their own viewer id "".
// For legacy plain burn_after_read pastes (no reads_limit), any read burns.
// Returns the number of reads remaining (0 when burned), or nil when no
// budget is set. view_count is tracked separately and unaffected.
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, count bool) {
// #58: admission is atomic. Returns (remaining, admitted). admitted is true
// only when this caller may serve the content: for legacy burn pastes the
// caller wins exactly when its conditional soft delete flipped deleted_at
// (RowsAffected), and for read-budget pastes the caller wins exactly when its
// conditional UPDATE (reads_used < reads_limit) incremented the counter - so
// concurrent readers can never both consume the last read. Losing callers
// must treat the paste as gone. view_count is tracked separately and
// unaffected.
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, admitted bool) {
if !row.ReadsLimit.Valid {
if row.BurnAfterRead {
s.SoftDelete(row.ID)
// #58: atomic claim - only the caller whose UPDATE actually
// flips deleted_at from NULL may serve the content.
ok, err := s.SoftDelete(row.ID)
r := 0
if err != nil || !ok {
return &r, false
}
return &r, true
}
return nil, false
return nil, true
}
now := TimeNow().Unix()
var last sql.NullInt64
@@ -44,14 +55,28 @@ func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes i
if r < 0 {
r = 0
}
return &r, false
return &r, true
}
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
row.ID, viewerID, now)
used := row.ReadsUsed + 1
s.db.Exec(`UPDATE pastes SET reads_used=? WHERE id=?`, used, row.ID)
if int64(used) >= row.ReadsLimit.Int64 {
// #58: conditional increment - only succeeds while budget remains, so
// concurrent readers cannot both consume the final read.
res, err := s.db.Exec(`UPDATE pastes SET reads_used = reads_used + 1
WHERE id = ? AND deleted_at IS NULL AND reads_used < ?`, row.ID, row.ReadsLimit.Int64)
if err != nil {
r := 0
return &r, false
}
if n, _ := res.RowsAffected(); n == 0 {
// Lost the race: budget exhausted (or paste already burned).
r := 0
return &r, false
}
var used int64
s.db.QueryRow(`SELECT reads_used FROM pastes WHERE id = ?`, row.ID).Scan(&used)
if used >= row.ReadsLimit.Int64 {
// Atomic burn; either way the paste is gone for future readers.
s.SoftDelete(row.ID)
}
r := int(row.ReadsLimit.Int64) - int(used)
+129 -22
View File
@@ -30,6 +30,8 @@ type Paste struct {
BurnAfterRead bool `json:"burn_after_read,omitempty"`
BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1)
Visibility string `json:"visibility"`
// #83: accept "public": true/false as an alias for visibility.
Public *bool `json:"public,omitempty"`
CanID *string `json:"can_id,omitempty"`
CreatedAt int64 `json:"created_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
@@ -60,6 +62,7 @@ type PasteRow struct {
Size int
DeletionToken sql.NullString
ViewerID sql.NullString
IsCan bool // set on list rows that are cans (#4)
}
type CanRow struct {
@@ -70,6 +73,8 @@ type CanRow struct {
CreatedAt int64
DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64
Description sql.NullString
ViewerID sql.NullString
}
type Store struct {
@@ -81,6 +86,11 @@ func OpenStore(path string) (*Store, error) {
if err != nil {
return nil, err
}
// #58: a single write connection. SQLite allows only one writer at a
// time; with multiple pooled connections concurrent writes surface as
// SQLITE_BUSY errors ("database is locked") instead of serializing, and
// the burn-after-read race tests saw spurious 500s under parallel reads.
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, err
@@ -125,6 +135,7 @@ deletion_token TEXT
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4)
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL,
@@ -200,16 +211,28 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
}
}
// #49: burn-after-read pastes carry a read budget (default 1 read)
if p.BurnAfterRead {
// #49/#82: burn-after-read pastes carry a read budget (default 1 read).
// burn_after_reads > 0 alone implies burn mode even without burn_after_read.
if p.BurnAfterRead || (p.BurnAfterReads != nil && *p.BurnAfterReads > 0) {
limit := int64(1)
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
limit = int64(*p.BurnAfterReads)
}
p.readsLimit = &limit
if !p.BurnAfterRead {
p.BurnAfterRead = true
}
}
visibility := p.Visibility
// #83: "public": false -> unlisted, true -> public; overrides string field
if p.Public != nil {
if *p.Public {
visibility = "public"
} else {
visibility = "unlisted"
}
}
if visibility == "" {
visibility = "public"
}
@@ -252,10 +275,19 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
return &r, err
}
// ListPublic backs /api/public and the public listing page. Visibility rules
// mirror the history page: only non-deleted, non-expired, non-can pastes are
// listed, and password-protected pastes are excluded at the query level
// (#65) so their metadata (title, slug, existence) never leaks.
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes
WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content), 0
FROM pastes
WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)
UNION ALL
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1
FROM paste_cans
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -264,25 +296,33 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
var isCan int
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil {
return nil, 0, err
}
r.CustomSlug = cs
r.Language = lang
r.Title = title
r.IsCan = isCan == 1
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total)
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?))
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
time.Now().Unix(), time.Now().Unix()).Scan(&total)
return out, total, nil
}
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content)
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content), 0
FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
UNION ALL
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1
FROM paste_cans
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -291,16 +331,18 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
var isCan int
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil {
return nil, 0, err
}
r.CustomSlug, r.Language, r.Title = cs, lang, title
r.IsCan = isCan == 1
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT COUNT(*) FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
viewerID, time.Now().Unix()).Scan(&total)
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?))
+ (SELECT COUNT(*) FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
return out, total, nil
}
@@ -320,19 +362,51 @@ func (s *Store) MineOwner(id string) (string, error) {
return vid.String, nil
}
func (s *Store) SoftDelete(id string) error {
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
return err
// SoftDelete marks a paste deleted (burned) atomically (#58): the deleted_at
// IS NULL guard means only the first caller flips the row. Returns true when
// this call performed the delete (RowsAffected > 0), false when the paste was
// already deleted - callers use this to decide read admission atomically.
func (s *Store) SoftDelete(id string) (bool, error) {
res, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (s *Store) IncrementViews(id string) {
// IncrementViews counts one view. With a viewerID (#95): views are deduped
// per-viewer within burnViewerWindow minutes using the paste_views table,
// namespaced with a "views/" viewer prefix so these rows never collide with
// RegisterRead's burn-after-read dedupe rows (which key on the raw viewer id).
// Raw views always count (#49 decision) — call with viewerID="" for those.
// Returns true when the view was counted.
func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool {
if viewerID == "" {
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
}
now := TimeNow().Unix()
vkey := "views/" + viewerID
var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
id, vkey).Scan(&last)
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
return false
}
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
id, vkey, now)
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
}
// SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace.
func (s *Store) SweepExpired() {
now := time.Now().Unix()
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
// #4: cans expire too — mirror paste behavior
s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
grace := now - SoftDeleteGraceDays*86400
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
}
@@ -389,13 +463,46 @@ func (s *Store) HardDelete(id string) {
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
}
// InsertCan creates a paste_can row.
func (s *Store) InsertCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64) error {
// CreateCan inserts a paste_can row with optional custom slug (parity with
// pastes: validated by the same rules, checked against both tables).
// Returns ErrSlugTaken / ErrInvalidSlug / ErrReservedSlug on conflict.
func (s *Store) CreateCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64, customSlug *string) error {
if customSlug != nil && *customSlug != "" {
slug := *customSlug
if err := ValidateCustomSlug(slug); err != nil {
return err
}
taken, err := s.SlugTaken(slug)
if err != nil {
return err
}
if taken {
return ErrSlugTaken
}
canID = slug
}
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
return errors.New("visibility must be public or unlisted")
}
_, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
return err
}
// SoftDeleteCan marks a can deleted (its items stay; they are unlisted and
// hidden from listings by can_id and disappear with the can's page).
func (s *Store) SoftDeleteCan(canID string) (bool, error) {
res, err := s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), canID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// DeleteCan removes an (empty/aborted) can row.
func (s *Store) DeleteCan(canID string) {
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
@@ -414,10 +521,10 @@ func (s *Store) InsertCanItem(canID, title, content, contentType string, languag
}
func (s *Store) GetCan(id string) (*CanRow, error) {
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at
FROM paste_cans WHERE id = ? AND deleted_at IS NULL`, id)
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at, description, viewer_id
FROM paste_cans WHERE (id = ?) AND deleted_at IS NULL`, id)
var c CanRow
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt)
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt, &c.Description, &c.ViewerID)
if err == sql.ErrNoRows {
return nil, nil
}
+46
View File
@@ -0,0 +1,46 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
// #59: SecurityHeaders must add the CSP and hardening headers to rendered
// HTML responses only; JSON and /raw responses pass through untouched.
func TestSecurityHeaders(t *testing.T) {
pages := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<html><body>ok</body></html>"))
})
h := SecurityHeaders(pages)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
t.Errorf("CSP = %q, want %q", got, wantCSP)
}
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Errorf("Referrer-Policy = %q, want no-referrer", got)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
// JSON/raw responses: headers are now set unconditionally BEFORE the handler
// runs. The previous post-handler approach was silently dropped once a page
// handler flushed its template output (headers must be set before WriteHeader).
// CSP/nosniff/referrer on non-HTML bodies is harmless and desirable.
jsonh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}))
rec = httptest.NewRecorder()
jsonh.ServeHTTP(rec, httptest.NewRequest("GET", "/api/x", nil))
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
t.Errorf("CSP missing on JSON response: got %q", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Errorf("Referrer-Policy missing on JSON response: got %q", got)
}
}
+244 -4
View File
@@ -40,6 +40,47 @@
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #274a6b;
}
/* matching light/dark counterparts: same hue family, flipped lightness roles */
/* Midnight Light: light counterpart of midnight (violet family) */
[data-preset="midnight-light"] {
--bg: #F3EFF9; --surface: #FAF8FD; --surface-2: #E1D9EF;
--muted: #A99CC9; --muted-fg: #5F5390; --fg: #2B2140;
--accent: #7B5FC0; --border: #D8CEEC;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #FAF8FD;
}
/* Smooth Dark: dark counterpart of smooth (muted slate-violet family) */
[data-preset="smooth-dark"] {
--bg: #23222E; --surface: #2C2B3A; --surface-2: #3B3A4D;
--muted: #7A7796; --muted-fg: #B5B1C9; --fg: #E8E7F0;
--accent: #A9A6C4; --border: #45435A;
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
--on-accent: #23222E;
}
/* Pastel Lavender Dark: dark counterpart (lavender family) */
[data-preset="pastel-lavender-dark"] {
--bg: #2A2340; --surface: #352C50; --surface-2: #4A3E6B;
--muted: #8F7FB8; --muted-fg: #CBBEE8; --fg: #EFEAF8;
--accent: #B39DE4; --border: #4E4272;
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
--on-accent: #2A2340;
}
/* Pastel Peach Dark: dark counterpart (peach family) */
[data-preset="pastel-peach-dark"] {
--bg: #3A241C; --surface: #4A2F24; --surface-2: #654234;
--muted: #B07A64; --muted-fg: #F0C4B0; --fg: #FBEBE2;
--accent: #F9826C; --border: #75503F;
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
--on-accent: #3A241C;
}
/* Pastel Cloud Dark: dark counterpart (pink cloud family, blue accent) */
[data-preset="pastel-cloud-dark"] {
--bg: #33222E; --surface: #402C3A; --surface-2: #583D50;
--muted: #A87E97; --muted-fg: #E5C2D6; --fg: #FAEEF5;
--accent: #A2D2FF; --border: #644759;
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
--on-accent: #33222E;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
@@ -143,7 +184,7 @@ body {
/* paste view */
.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.meta-bar h1 { font-size: 29.2px; font-weight: 600; }
.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: var(--radius); }
.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: var(--radius-sm); }
.tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; }
.paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
@@ -162,10 +203,45 @@ body {
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 5px 12px; font: inherit; font-size: 21.6px; cursor: pointer; text-decoration: none; }
.iconbtn.gear { display: inline-flex; align-items: center; padding: 5px 9px; }
.iconbtn.gear svg { width: 22px; height: 22px; }
/* #127: gear highlights like selected nav tabs; dark toggle button */
.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; }
.iconbtn.dark-toggle svg { width: 22px; height: 22px; }
.iconbtn.dark-toggle .icon-moon { display: none; }
.iconbtn.dark-toggle .icon-sun { display: inline-block; }
.dark .iconbtn.dark-toggle .icon-sun { display: none; }
.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; }
/* #127: gear highlights like selected nav tabs; dark toggle button */
.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; }
.iconbtn.dark-toggle svg { width: 22px; height: 22px; }
.iconbtn.dark-toggle .icon-moon { display: none; }
.iconbtn.dark-toggle .icon-sun { display: inline-block; }
.dark .iconbtn.dark-toggle .icon-sun { display: none; }
.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; }
.settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); }
.settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
.settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; }
.settings-section-title { color: var(--fg); font-size: 24px; margin-bottom: 6px; }
/* theme switcher cards (#100) */
.theme-grid { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; }
.theme-card {
display: flex; flex-direction: column; gap: 10px; align-items: flex-start;
padding: 14px 16px; min-width: 180px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--fg); font: inherit; font-size: 20px; cursor: pointer;
}
.theme-card:hover { border-color: var(--muted); }
.theme-card[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-card .swatches { display: flex; gap: 4px; }
.theme-card .swatch { width: 26px; height: 26px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); }
.theme-name { font-weight: 600; }
.iconbtn:hover { color: var(--fg); border-color: var(--muted); }
/* #130: line wrap toggle, accent when active like other toggles */
.iconbtn.wrap-toggle[aria-pressed="true"],
.btn-icon.wrap-toggle[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); }
html[data-wrap] .codebody { white-space: pre-wrap; }
html[data-wrap] .editor { white-space: pre-wrap; }
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
.code-head {
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
@@ -201,7 +277,8 @@ th {
text-align: left; font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600;
}
td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: clip; text-overflow: ellipsis; white-space: nowrap; }
td:has(> a.slug), td:has(> .slug) { overflow: visible; }
tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); }
@@ -281,6 +358,13 @@ td a.slug:hover { color: var(--accent); }
.toast.success { border-color: var(--ok); color: var(--ok); }
.toast.error { border-color: var(--err); color: var(--err); }
/* result box color-coding (#105): colored left border + text tint per status */
.result-ok, .result-err, .result-warn { border-left: 4px solid transparent; padding-left: 10px; }
.result-ok { border-left-color: var(--ok); color: var(--ok); }
.result-err { border-left-color: var(--err); color: var(--err); }
.result-warn { border-left-color: var(--warn); color: var(--warn); }
.result-ok a { color: var(--ok); }
/* protection section rhythm (#20) */
.protect { display: flex; flex-direction: column; gap: 2px; }
.protect .pw-row { padding: 2px 8px 4px; }
@@ -333,17 +417,29 @@ td a.slug:hover { color: var(--accent); }
/* paste name under slug pill in Paste column (#43) */
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paste-sub.dim { color: var(--muted); }
td .url-link { font-size: 19.8px; }
td a.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 .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); }
/* sortable column headers (#42) */
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
.sort-ind { display: inline-block; width: 0; height: 0; margin-left: 6px; vertical-align: middle; border-left: 5px solid transparent; border-right: 5px solid transparent; }
th.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
/* #101: arrow sits LEFT of the label, further spaced, vertically centered,
and absolutely positioned so it never shifts the label text. */
th.sortable { position: relative; padding-left: 24px; }
th.sortable .sort-ind {
position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
display: inline-block; width: 0; height: 0;
border-left: 5px solid transparent; border-right: 5px solid transparent;
}
th.sorted.asc .sort-ind { border-bottom: 6px solid var(--accent); }
th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
/* #102: small unobtrusive admin link on the settings page */
a.admin-link { color: var(--muted-fg); font-size: 15px; text-decoration: none; }
a.admin-link:hover { color: var(--fg); text-decoration: underline; }
/* ============================================================
Consistency audit (#18) — shared tokens across inputs, buttons,
headings. Visual-only, no behavior change.
@@ -405,6 +501,12 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.topbar nav a { padding: 5px 8px; font-size: 15px; }
.iconbtn.gear { padding: 4px 7px; flex-shrink: 0; }
.iconbtn.gear svg { width: 18px; height: 18px; }
.iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; }
.iconbtn.dark-toggle svg { width: 18px; height: 18px; }
.iconbtn.dark-toggle span { display: none; }
.iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; }
.iconbtn.dark-toggle svg { width: 18px; height: 18px; }
.iconbtn.dark-toggle span { display: none; }
/* new paste: stack editor above sidebar, natural page height */
.deck {
@@ -456,3 +558,141 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.unlock-err { font-size: 13px; }
.center .foot { font-size: 13px; }
}
/* #4: can view page + can builder */
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.can-item { padding: 12px 14px; }
.can-item-head { display: flex; align-items: center; gap: 10px; }
.can-item-head strong { flex: 1; word-break: break-all; }
.can-item-body { margin-top: 8px; }
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
.can-item-body pre.code { margin: 8px 0 0; overflow-x: auto; }
.can-item-row { margin-top: 8px; }
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
/* ============================================================
Styled controls (#123) — buttons, selects, checkboxes, radios
match the custom URL box house pattern: 1px var(--border) on
var(--bg), var(--radius) corners, accent border on focus, no
default browser chrome (appearance: none).
============================================================ */
/* selects: replace native chrome with a house-styled pill */
select {
appearance: none;
-webkit-appearance: none;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg) url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center;
color: var(--fg);
font: inherit;
padding: 4px 30px 4px 12px;
cursor: pointer;
outline: none;
transition: border-color .12s ease, background-color .12s ease;
}
select:hover { border-color: var(--muted-fg); background-color: var(--surface-2); }
select:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
.editor-head select { padding: 4px 30px 4px 10px; }
/* checkboxes and radios: custom drawn, accent when checked */
input[type="checkbox"],
input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 16px; height: 16px; margin: 0; flex-shrink: 0;
border: 1px solid var(--border);
background: var(--bg);
cursor: pointer;
position: relative;
transition: border-color .12s ease, background-color .12s ease;
}
input[type="checkbox"] { border-radius: 4px; }
input[type="radio"] { border-radius: 50%; }
input[type="checkbox"]:hover,
input[type="radio"]:hover { border-color: var(--muted-fg); }
input[type="checkbox"]:checked,
input[type="radio"]:checked { background: var(--accent); border-color: var(--accent); }
input[type="checkbox"]:checked::after {
content: "";
position: absolute; inset: 0;
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10'%3E%3Cpath d='M1 5l4 4 6-8' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 11px no-repeat;
}
input[type="radio"]:checked::after {
content: "";
position: absolute; inset: 3px;
border-radius: 50%;
background: var(--bg);
}
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* buttons: every submit-style button gets the .btn treatment,
and .btn itself gains the house surface/border/hover pattern */
button[type="submit"] {
background: var(--accent); color: var(--bg); border: 1px solid var(--accent);
border-radius: var(--radius); font: inherit; font-weight: 600;
padding: 8px 18px; cursor: pointer;
transition: filter .12s ease;
}
button[type="submit"]:hover { filter: brightness(1.08); }
button[type="submit"]:focus-visible,
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn {
border: 1px solid var(--accent);
}
.btn:hover { filter: brightness(1.08); }
/* settings: give the Admin link clear separation from the theme grid */
.admin-link-row {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
/* #38: attachment upload on /new + attachment display on paste view */
.side-section .file-input { display: none; }
.dropzone {
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
padding: 14px 12px;
text-align: center;
color: var(--muted-fg);
cursor: pointer;
font-size: 20.7px;
transition: border-color .15s, background .15s;
background: var(--surface-2, transparent);
}
.dropzone:hover, .dropzone.dragover {
border-color: var(--accent);
background: var(--surface-2);
}
.dropzone.dragover { border-width: 2px; }
.file-chip {
display: flex; align-items: center; gap: 10px;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 8px 14px; margin-top: 8px; background: var(--surface);
font-size: 21.6px;
}
.file-chip .file-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.file-chip .file-chip-size { color: var(--muted-fg); font-size: 19px; }
.file-chip .file-chip-remove {
border: none; background: none; color: var(--muted-fg); cursor: pointer;
font-size: 22px; padding: 0 4px; border-radius: var(--radius-sm);
}
.file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); }
.attachment-bar { display: flex; flex-direction: column; gap: 10px; }
.attachment-chip {
display: inline-flex; align-items: center; gap: 12px; align-self: flex-start;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 8px 16px; text-decoration: none; color: var(--fg);
background: var(--surface-2); font-size: 21.6px;
}
.attachment-chip:hover { border-color: var(--accent); }
.attachment-chip .attachment-size { color: var(--muted-fg); font-size: 19px; }
.attachment-preview img { max-width: 480px; max-height: 360px; border-radius: var(--radius); border: 1px solid var(--border); }
+1 -1
View File
@@ -63,7 +63,7 @@ const PaletteTable = (() => {
const filtered = state.filter.length > 0;
const off = (state.page - 1) * opts.perPage;
const url = (filtered || state.sortKey)
? opts.endpoint + '?limit=500&offset=0'
? opts.endpoint + '?limit=' + (opts.fetchLimit || 100) + '&offset=0'
: opts.endpoint + '?limit=' + opts.perPage + '&offset=' + off;
const res = await fetch(url);
const data = await res.json();
+8 -4
View File
@@ -44,7 +44,8 @@
function api(path, opts) {
opts = opts || {};
opts.headers = { 'X-Admin-Key': key() };
// allow callers to override the key header (e.g. validating a typed key, #99)
opts.headers = Object.assign({ 'X-Admin-Key': key() }, opts.headers || {});
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
@@ -72,9 +73,10 @@
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
sessionStorage.setItem(KEY, keyInput.value);
api('/admin/api/settings').then(function (r) {
// #99: don't persist the key until the server accepts it
api('/admin/api/settings', { headers: { 'X-Admin-Key': keyInput.value } }).then(function (r) {
if (r.status === 200) {
sessionStorage.setItem(KEY, keyInput.value);
status.textContent = '✓';
keyInput.value = '';
loadSettings();
@@ -101,7 +103,9 @@
});
});
if (key()) loadSettings();
// #112: always show the lock on fresh load — do not auto-restore the
// panel from a stale sessionStorage key. The key is only written after a
// successful unlock (above) so in-page actions still work within this visit.
})();
</script>
{{template "foot" .}}
+36
View File
@@ -0,0 +1,36 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float can-page" style="max-width:900px; width:100%;">
<div class="inner">
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
<p class="hint">{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}</p>
<div class="can-items">
{{range .Items}}
<div class="can-item float">
<div class="can-item-head">
<strong>{{.Title}}</strong>
<span class="dim">{{.Size}}</span>
{{if .IsFile}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" download="{{.Title}}" title="Download file" aria-label="Download {{.Title}}"></a>
{{else}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" title="Raw item" aria-label="Raw {{.Title}}"></a>
{{end}}
</div>
{{if not .IsFile}}
<details class="can-item-body">
<summary>Show content</summary>
<pre class="code"><code>{{.ContentHTML}}</code></pre>
</details>
{{else}}
<div class="hint">Attached file — use the download button above.</div>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
{{template "foot" .}}
+2 -2
View File
@@ -36,8 +36,8 @@ const t = PaletteTable.init({
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.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>` +
+122 -4
View File
@@ -3,17 +3,86 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
<script>
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
// preset hook (#16): ?theme= wins; else persisted choice (#100).
// #127: theme pairs - each base theme has a light and dark variant;
// palette-theme stores the base id, palette-dark the dark flag.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
function resolve(t, dark) {
for (var base in PAIRS) {
if (t === PAIRS[base].light || t === PAIRS[base].dark) return t;
if (t === base) return dark ? PAIRS[base].dark : PAIRS[base].light;
}
return null;
}
var t = new URLSearchParams(location.search).get('theme');
if (t) document.documentElement.dataset.preset = t;
// precedence: URL ?theme= > stored pair+dark prefs > server default dark > dark on
var dark = {{ defaultDark }};
try {
var stored = localStorage.getItem('palette-dark');
if (stored !== null) dark = stored !== 'false';
} catch (e) {}
if (t) {
// URL ?theme= wins and accepts both base and variant ids; a variant id
// also sets the dark flag and normalizes t back to its base pair id.
var found = false;
for (var base in PAIRS) {
if (t === PAIRS[base].dark) { dark = true; t = base; found = true; break; }
if (t === PAIRS[base].light) { dark = false; t = base; found = true; break; }
if (t === base) { found = true; break; }
}
if (!found) t = null;
}
if (!t) {
try { t = localStorage.getItem('palette-theme'); } catch (e) {}
}
if (!PAIRS[t]) t = 'midnight';
// do not persist anything here: URL theme is a one-off override
document.documentElement.dataset.preset = dark ? PAIRS[t].dark : PAIRS[t].light;
})();
// #130: shared line wrap preference (localStorage 'palette-wrap', default off)
(function () {
function wrapOn() {
try { return localStorage.getItem('palette-wrap') === '1'; } catch (e) { return false; }
}
function wrapApply(on) {
if (on) document.documentElement.setAttribute('data-wrap', '1');
else document.documentElement.removeAttribute('data-wrap');
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
var s = document.getElementById('wrap-setting');
if (s) s.checked = on;
}
window.paletteWrapToggle = function () {
var on = !wrapOn();
try { localStorage.setItem('palette-wrap', on ? '1' : '0'); } catch (e) {}
wrapApply(on);
return on;
};
document.addEventListener('DOMContentLoaded', function () {
wrapApply(wrapOn());
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
b.addEventListener('click', function () { window.paletteWrapToggle(); });
});
var s = document.getElementById('wrap-setting');
if (s) s.addEventListener('change', function () {
if (s.checked !== wrapOn()) window.paletteWrapToggle();
});
});
})();
</script>
{{end}}
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</em></a>
<a class="logo" href="/history">Palette <em>/ {{ version }}</em></a>
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
@@ -21,8 +90,57 @@
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
</nav>
<div class="spacer"></div>
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
<button class="iconbtn dark-toggle" id="dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<a class="iconbtn gear{{if eq .Page "settings"}} on{{end}}" href="/settings" title="Settings" aria-label="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</a>
</div>
<script>
// #127: dark mode toggle - flips to the other variant of the active pair.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
var root = document.documentElement;
function state() {
var p = root.dataset.preset || 'midnight';
for (var base in PAIRS) {
if (p === PAIRS[base].dark) return { base: base, dark: true };
if (p === PAIRS[base].light) return { base: base, dark: false };
}
return { base: 'midnight', dark: true };
}
function apply() {
var s = state();
document.body.classList.toggle('dark', s.dark);
root.classList.toggle('dark', s.dark);
}
function sync(btns) {
btns.forEach(function (b) {
b.setAttribute('aria-pressed', state().dark ? 'true' : 'false');
});
}
var btns = document.querySelectorAll('.dark-toggle');
apply();
sync(Array.prototype.slice.call(btns));
btns.forEach(function (b) {
b.addEventListener('click', function () {
var s = state();
var dark = !s.dark;
root.dataset.preset = dark ? PAIRS[s.base].dark : PAIRS[s.base].light;
try { localStorage.setItem('palette-dark', dark ? 'true' : 'false'); } catch (e) {}
apply();
sync(Array.prototype.slice.call(btns));
document.dispatchEvent(new CustomEvent('palette-darkchange'));
});
});
})();
</script>
{{end}}
+8 -4
View File
@@ -21,6 +21,10 @@
</table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
</div>
<div class="pager float">
<span id="showing"></span>
<div class="pg" id="pg"></div>
</div>
</div>
<script src="/static/table.js"></script>
<script>
@@ -38,13 +42,13 @@ function toast(msg, kind) {
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 50,
hasPager: false,
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.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>` +
+35 -6
View File
@@ -16,6 +16,7 @@
<option>markdown</option><option>text</option>
</select>
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>
<button type="button" class="btn btn-icon wrap-toggle" id="wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button>
</div>
</div>
<div class="float editor-wrap">
@@ -65,8 +66,7 @@
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<input type="text" id="custom" class="custom-input" placeholder="/my-snippet">
<div class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
<input type="text" id="custom" class="custom-input" placeholder="my-snippet">
</div>
<div class="float side-section" id="result-card" style="display:none">
<h3>Result</h3>
@@ -147,10 +147,37 @@ $('pwreveal').addEventListener('click', () => {
let guessed = ''; // last auto-detected language, '' = user override
function showResult(html, isError) {
// #105: map backend machine-readable error codes to plain-language guidance.
// Unknown codes fall back to a generic message; the technical detail stays
// in the API response for API consumers.
const ERROR_MESSAGES = {
slug_taken: 'That Custom URL is already taken. Try another.',
slug_reserved: 'That Custom URL is reserved. Try another.',
slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.',
content_empty: 'Write or paste something first.',
content_too_large: 'This paste is too large. The limit is 5 MB.',
expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.',
rate_limited: 'Too many tries. Wait a minute and try again.',
};
const GENERIC_ERROR = 'Something went wrong. Please try again.';
function friendlyError(data) {
return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR;
}
// #105: color the result box by outcome — success (ok), error (err),
// warning (warn) — with a colored left border (CSS .result-ok/.result-err).
function setResultKind(kind) {
const card = $('result-card');
card.classList.remove('result-ok', 'result-err', 'result-warn');
if (kind) card.classList.add('result-' + kind);
}
function showResult(html, kind) {
$('result').innerHTML = html;
$('result').dataset.token = isError ? '' : ($('result').dataset.token || '');
$('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || '');
$('result-card').style.display = 'block';
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
}
function defaultFilename(lang) {
const names = {
@@ -197,6 +224,7 @@ $('reguess').addEventListener('click', guessLang);
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
const body = {
content: content.value,
title: $('title').value || null,
@@ -222,12 +250,12 @@ async function create() {
});
const data = await res.json();
if (!res.ok) {
showResult('Error: ' + (data.error || res.status), true);
showResult(friendlyError(data), 'err');
toast('Create failed', 'error');
return;
}
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
@@ -252,6 +280,7 @@ async function create() {
location.href = dest;
}
$('create').addEventListener('click', create);
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
+1
View File
@@ -6,6 +6,7 @@
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
<div class="spacer"></div>
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
+90 -1
View File
@@ -6,8 +6,97 @@
<h1>Settings</h1>
</div>
<div class="settings-body">
<p>Settings are under construction.</p>
<h3>Theme</h3>
<div class="theme-grid" id="theme-grid"></div>
<button class="iconbtn dark-toggle" id="settings-dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span>Dark mode</span>
</button>
<h3 style="margin-top:18px">Editor</h3>
<label class="toggle" for="wrap-setting" style="display:inline-flex"><input type="checkbox" id="wrap-setting"> Line wrap</label>
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
</div>
</div>
</div>
<script>
(function () {
// #112: derive each preset's swatches from the real CSS variables in
// app.css by temporarily applying data-preset, so they can never drift.
// #127: 5 theme pairs, light swatches top row, dark bottom row.
var pairs = [
{ id: 'midnight', name: 'Midnight' },
{ id: 'smooth', name: 'Smooth' },
{ id: 'pastel-lavender', name: 'Pastel Lavender' },
{ id: 'pastel-peach', name: 'Pastel Peach' },
{ id: 'pastel-cloud', name: 'Pastel Cloud' }
];
var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent'];
function presetColors(id) {
var root = document.documentElement;
var prev = root.getAttribute('data-preset');
root.setAttribute('data-preset', id);
var cs = getComputedStyle(root);
var colors = SWATCH_VARS.map(function (v) { return cs.getPropertyValue(v).trim(); });
if (prev === null) root.removeAttribute('data-preset'); else root.setAttribute('data-preset', prev);
return colors;
}
// current base pair + dark flag from the resolved data-preset.
// "midnight" is itself the dark variant, so check dark ids first.
function state() {
var p = document.documentElement.dataset.preset || 'midnight';
if (p === 'midnight' || /-dark$/.test(p)) {
return { base: p === 'midnight' ? 'midnight' : p.replace(/-dark$/, ''), dark: true };
}
return { base: p === 'midnight-light' ? 'midnight' : p, dark: false };
}
var grid = document.getElementById('theme-grid');
var cards = {};
pairs.forEach(function (t) {
var light = presetColors(t.id);
var dark = presetColors(t.id + '-dark');
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-card';
btn.setAttribute('aria-pressed', 'false');
btn.setAttribute('data-pair', t.id);
btn.innerHTML = '<strong>' + t.name + '</strong>' +
'<span class="swatches">' + light.map(function (c) {
return '<span class="swatch" style="background:' + c + '"></span>';
}).join('') + '</span>' +
'<span class="swatches">' + dark.map(function (c) {
return '<span class="swatch" style="background:' + c + '"></span>';
}).join('') + '</span>';
btn.addEventListener('click', function () {
var dark = state().dark;
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true');
});
cards[t.id] = btn;
grid.appendChild(btn);
});
function sync() {
var s = state();
Object.keys(cards).forEach(function (k) {
cards[k].setAttribute('aria-pressed', k === s.base ? 'true' : 'false');
});
var dt = document.getElementById('settings-dark-toggle');
if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false');
}
sync();
// the topbar script runs before this button exists, so wire it here
var dt = document.getElementById('settings-dark-toggle');
dt.addEventListener('click', function () {
var btns = document.querySelectorAll('.topbar .dark-toggle');
if (btns.length) btns[0].click(); else document.dispatchEvent(new CustomEvent('palette-darkchange'));
});
document.addEventListener('palette-darkchange', sync);
})();
</script>
{{template "foot" .}}
+6
View File
@@ -0,0 +1,6 @@
package web
// Version is the display version, set at build time via
// -ldflags "-X palette/internal/web.Version=v0.2.1" (#93). Falls back to
// "dev" for bare `go build` runs.
var Version = "dev"
+107 -8
View File
@@ -24,6 +24,44 @@ import (
//go:embed templates/*.html
var tmplFS embed.FS
// DefaultDark is the server-configured default dark mode state for visitors
// without stored preferences (#127). Set via PALETTE_DEFAULT_DARK.
var DefaultDark = true
// SetDefaultDark applies the PALETTE_DEFAULT_DARK env var: unset = dark on,
// "false"/"0"/"off" = dark off.
func SetDefaultDark(v string) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "false", "0", "off":
DefaultDark = false
default:
DefaultDark = true
}
}
// ResolvePreset maps a theme pair id (or explicit variant id) plus dark state
// to a concrete preset id. Returns the defaults when pair is empty or unknown.
// Precedence handled by callers: URL ?theme= > stored pair+dark > defaults.
func ResolvePreset(pair string, dark bool) string {
variants := map[string][2]string{ // pair -> {light, dark}
"midnight": {"midnight-light", "midnight"},
"smooth": {"smooth", "smooth-dark"},
"pastel-lavender": {"pastel-lavender", "pastel-lavender-dark"},
"pastel-peach": {"pastel-peach", "pastel-peach-dark"},
"pastel-cloud": {"pastel-cloud", "pastel-cloud-dark"},
}
if v, ok := variants[pair]; ok {
if dark {
return v[1]
}
return v[0]
}
if dark {
return "midnight"
}
return "midnight-light"
}
//go:embed static
var staticFS embed.FS
@@ -33,7 +71,9 @@ type UI struct {
func New() (*UI, error) {
funcs := template.FuncMap{
"humanSize": humanSize,
"humanSize": humanSize,
"version": func() string { return Version }, // #93: topbar version label
"defaultDark": func() bool { return DefaultDark }, // #127: server-configured default
}
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
if err != nil {
@@ -64,6 +104,29 @@ func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
}
}
// RenderPage is the exported wrapper used by the api package (#4 can pages).
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
h.renderPage(w, name, data)
}
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
h.writeRateLimited(w, retryAfterSecs)
}
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
// Exported for the api package so can pages share paste cookie semantics (#4).
func UnlockToken(id string) string { return unlockToken(id) }
// AgoString formats a relative "N ago" string (exported for the api package, #4).
func AgoString(ts int64) string { return agoString(ts) }
// HumanSize formats a byte count as a human string (exported for the api package, #4).
func HumanSize(n int) string { return humanSize(n) }
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
@@ -245,17 +308,22 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
// one-time display of the deletion token via the created banner
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
// Count the view for every real page render. Raw views increment in
// handleRaw; the HTML path was missing its increment, so view_count only
// ever moved via /raw and the API-stored count stayed at 0 (#33).
// The just-created banner render does not count as a view.
// Count the view for real page renders, deduped per-viewer within the
// burn window (#95): a reload by the same vwr cookie doesn't inflate
// view_count. Raw views increment unconditionally in handleRaw (#49); the
// HTML path was missing its increment originally (#33). The just-created
// banner render does not count as a view.
if !justCreated {
h.Store.IncrementViews(row.ID)
h.Store.IncrementViews(row.ID, h.ViewerID(r), h.BurnWindowMin())
}
// #49: burn-after-N-reads budget (per-viewer dedupe window).
// Just-created first render does not count as a read for the creator.
if !justCreated {
rem, _ := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
if !admitted { // #58: lost the burn claim; do not render content
http.NotFound(w, r)
return
}
h.renderPaste(w, row, false, "", rem)
return
}
@@ -275,7 +343,16 @@ func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
// HandleSettingsPage serves /settings.
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "settings.html", map[string]any{"Page": "settings"})
// #100: theme presets. Midnight is the default (no data-preset attribute),
// so its swatches are hardcoded here; the CSS defines the token values.
themes := []map[string]any{
{"Name": "midnight", "Label": "Midnight", "Swatches": []string{"#241B30", "#2D2340", "#3A2D52", "#C4A8F0", "#F2EDF8"}},
{"Name": "smooth", "Label": "Smooth", "Swatches": []string{"#F6F5FA", "#FFFFFF", "#DAD7E6", "#7A7796", "#2A2A36"}},
{"Name": "pastel-lavender", "Label": "Pastel Lavender", "Swatches": []string{"#e6e0f5", "#f1edfa", "#cbb8e7", "#806bb8", "#3E3059"}},
{"Name": "pastel-peach", "Label": "Pastel Peach", "Swatches": []string{"#ffe0d6", "#fff0ea", "#ffc4a8", "#f9826c", "#4F2318"}},
{"Name": "pastel-cloud", "Label": "Pastel Cloud", "Swatches": []string{"#fff0f6", "#fff7fb", "#ffc8dd", "#a2d2ff", "#4A3355"}},
}
h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes})
}
// HandleMinePage serves /mine.
@@ -290,3 +367,25 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
// Handlers builds a web.Handlers bound to this UI.
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
// #59: security headers for rendered HTML pages. Applied wherever the
// response is text/html (page templates and the inline can page); JSON API
// responses and /raw content pass through untouched. script-src allows
// 'unsafe-inline' because the page templates carry inline scripts; CSP
// default-src 'self' still blocks external content and object/frame embeds,
// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34
// pentest. Runs after the handler so the Content-Type is already set.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set before the handler runs: once a handler writes (template render
// flushes), header mutations are silently dropped. Setting the headers
// unconditionally is safe: CSP/nosniff/referrer on JSON or /raw bodies
// is harmless and arguably desirable.
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
+48
View File
@@ -0,0 +1,48 @@
package web
import "testing"
func TestResolvePreset(t *testing.T) {
cases := []struct {
pair string
dark bool
want string
}{
{"", false, "midnight-light"},
{"", true, "midnight"},
{"midnight", false, "midnight-light"},
{"midnight", true, "midnight"},
{"smooth", false, "smooth"},
{"smooth", true, "smooth-dark"},
{"pastel-lavender", true, "pastel-lavender-dark"},
{"pastel-peach", false, "pastel-peach"},
{"pastel-cloud", true, "pastel-cloud-dark"},
{"bogus", true, "midnight"},
{"bogus", false, "midnight-light"},
// explicit variant ids also resolve (URL ?theme= may pass them)
{"smooth-dark", true, "midnight"},
}
for _, c := range cases {
if got := ResolvePreset(c.pair, c.dark); got != c.want {
t.Errorf("ResolvePreset(%q, %v) = %q, want %q", c.pair, c.dark, got, c.want)
}
}
}
func TestSetDefaultDark(t *testing.T) {
for _, off := range []string{"false", "0", "off", "OFF", " false "} {
DefaultDark = true
SetDefaultDark(off)
if DefaultDark {
t.Errorf("SetDefaultDark(%q): want dark off", off)
}
}
for _, on := range []string{"true", "1", "on", "anything"} {
DefaultDark = false
SetDefaultDark(on)
if !DefaultDark {
t.Errorf("SetDefaultDark(%q): want dark on", on)
}
}
DefaultDark = true
}