63 Commits
Author SHA1 Message Date
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 03bf327f6b fix: anchor gitignore binary pattern, track cmd/palette
CI / test (push) Successful in 22s
CI / docker (push) Successful in 44s
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-09 02:04:03 -05:00
poslop a604c5906a fix: track cmd/palette (was untracked after refactor), untrack committed binary
CI / test (push) Successful in 17s
CI / docker (push) Failing after 22s
2026-09-09 02:02:57 -05:00
poslop 2c1b2818a8 CI: use static docker CLI download (no apt package in bookworm)
CI / test (push) Successful in 18s
CI / docker (push) Failing after 22s
2026-09-09 01:53:30 -05:00
poslop b1187e061f CI: install docker CLI in docker job (debian image lacks it)
CI / test (push) Successful in 19s
CI / docker (push) Failing after 30s
2026-09-09 01:45:53 -05:00
poslop 4f1e901f04 Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
CI / test (push) Successful in 19s
CI / docker (push) Failing after 2m7s
2026-09-09 01:33:39 -05:00
poslop a3349b4a98 Admin endpoint: ENV/file admin key, --reset-admin-key, settings API wired into ratelimit/max-bytes/default-expiry/burn-window (#40)
CI / test (push) Successful in 22s
CI / docker (push) Skipped
2026-09-09 00:44:02 -05:00
poslop 03600b2ed5 sweep: fix missing view_count on HTML views, API expiry bounds, search ignoring custom slug (#33)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 00:34:24 -05:00
poslop cb23707125 pentest: bind unlock cookie to HMAC per-paste token; serve only safe content types on /raw and can items with nosniff (#34)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 00:14:01 -05:00
poslop 15ce7ff011 Fix unclosed composeCustomExpiry brace that broke page JS (#48)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-08 23:58:54 -05:00
poslop 238dc96454 README rework: user-first structure, features, screenshots, config table; API detail moved to docs/API.md (#47)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-08 23:54:54 -05:00
poslop d5a47b1a31 Burn after N reads: reads_limit/reads_used, per-viewer 15min dedupe via paste_views, reads_remaining in API+stats pill, raw counts as read (#49)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-08 23:54:03 -05:00
poslop 127c12c79a Custom expiry: Custom radio with number+unit select, client-side validation 1min-1year, Go-duration composition (#48) 2026-09-08 23:54:03 -05:00
poslop 2e1ce508fa Saved page: live search + sortable columns via shared table module (#57)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- new web/static/table.js (PaletteTable): shared live search, client-side
  sort with indicators, pagination, row rendering + click-through
- history.html and mine.html both consume it; data endpoint, columns,
  empty-state text and row extras (delete buttons) are page-supplied
- verified in-browser: search, sort, delete on /mine; sort, pager on /history

Closes #57
2026-09-08 22:39:41 -05:00
poslop 76d7ac12e7 Small fixes: gutter alignment, paste column, sort arrows, copy feedback, stats bar, title slug (#50-#55)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- #50 gutter/code line misalignment: shared --code-lh/--code-fs tokens;
  .code .gutter drops its own vertical padding (was 14px vs 0, 14px offset)
- #51 history Paste column shows only the paste name, or slug pill when untitled
- #52 sort indicator arrow sits LEFT of the column label for all sortable columns
- #53 copy buttons give in-place success feedback ('Success!' in --ok for 2s):
  paste view copy button and new-page result copy button
- #54 collapsed stats summary bar: roomier padding (14px 18px) and item gap (16px);
  wraps gracefully on mobile
- #55 paste view title bar: slug pill only shown for custom slugs, id not repeated

Closes #50, closes #51, closes #52, closes #53, closes #54, closes #55
2026-09-08 22:35:43 -05:00
poslop 129934b645 My pastes page /mine with anonymous viewer cookie (#37)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- vwr cookie middleware: random browser id set on first visit (reused by #49)
- pastes table gains viewer_id column, set server-side at creation from the cookie
- GET /api/mine lists pastes for the requesting browser (title/lang/size/created)
- DELETE enforcement: 403 when client-sent vwr doesn't match the paste's viewer_id
- /mine page reuses history table styling, delete buttons, empty state
- nav: 'Saved' item between Public and Git; Git gets external-link arrow (#56)
- tests: create-with-cookie appears in /mine, other cookie doesn't, delete enforcement

Closes #37
2026-09-08 22:10:05 -05:00
poslop e83303a428 Consistency audit: shared input/button/heading tokens across pages (#18)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- One text-input treatment (size, color, placeholder) for .search input,
  .pw-field input, #title, #custom, .row text inputs
- Icon-only buttons (.iconbtn, .btn-icon) share .btn radius + focus ring
- Unified heading letter-spacing with .side-section h3 pattern
- Normalized settings card padding to the 12/18px wide-card scale
- Verified visually: before/after screenshots for new/history/settings/unlock

Closes #18
2026-09-08 22:00:21 -05:00
poslop acf71f7444 Palettes: pastel-lavender/peach/cloud presets + semantic status colors (#16)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- Three new light [data-preset] blocks alongside midnight (default) and smooth
- --ok/--warn/--err semantic tokens with 4.5:1 contrast per preset
- .toast success/error variants wired in new.html
- ?theme= query param preview hook in layout head (screenshots only)
- Preview screenshots in docs/palette-previews/

Closes #16
2026-09-08 21:56:36 -05:00
poslop 9c4689e6de Mobile responsiveness: media query for 375-640px across topbar, deck, history, paste view, unlock (#45)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-08 21:45:42 -05:00
poslop ebcf7eec80 Live relative-time counters via shared [data-ts] ticker in layout foot (#46)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-08 21:41:29 -05:00
poslop b3112d2a35 History: sortable column headers with client-side sort (#42)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-08 21:36:53 -05:00
poslop 6a6f4d1587 History: URL + ID columns at end, custom_slug in /api/public (#44)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-08 21:33:08 -05:00
poslop e31aa44ecb History: paste name under Paste column, drop Description column (#43)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-08 21:32:10 -05:00
poslop 5b7198fb3c Settings gear in topbar linking to placeholder /settings page (#36)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
- layout.html: inline SVG gear icon top-right, styled as .iconbtn.gear pill
- new settings.html template reusing standard layout with under-construction card
- register GET /settings route in main.go
2026-09-08 21:27:02 -05:00
poslop efa8c7145c Language detection: switch to go-enry with hint-seeded classifier (#41)
CI / test (push) Successful in 35s
CI / docker (push) Skipped
- Add github.com/go-enry/go-enry/v2 dependency to go.mod
- guessLang: JSON fast path, then enry strategies (shebangs, XML, modelines,
  content heuristics), then enry's Linguist-trained Bayesian classifier over
  regex-hint candidate languages; curated dropdown expanded with typescript,
  html, css, xml, php, ruby, perl, lua, dockerfile, toml, ini, diff, csharp
- enry display names normalized to lowercase stored ids (Dockerfile->dockerfile,
  Shell->bash, C#->csharp); unsupported languages render unhighlighted
- Tests: existing languages, all new languages, magic markers (enry shebang/
  DOCTYPE/FROM/diff handling), canonical mapping
2026-09-08 21:25:36 -05:00
poslop 7410b5c9cf Paste view: collapsible stats pill under title (#17)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-08 21:14:44 -05:00
poslop 3facff3d1e Syntax highlighting, rate limiting, creator auto-unlock (#1, #2, #26)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
#1: server-side regex highlighter (highlight.go) for go/python/js/json/bash/sql;
token span classes styled in app.css; per-line so gutter stays aligned.
#2: in-memory token-bucket rate limiter (ratelimit.go) on POST /api/pastes,
/api/guess-language and unlock POST; 429 + Retry-After + X-RateLimit headers.
#26: new-page JS POSTs the password to /{id} with ?next= after creation; the
unlock handler honors same-origin ?next= redirect so the creator lands on the
unlocked paste. POST /{id} route added.

Tests: ratelimit_test.go (burst/429, refill, unlock limit, highlight, auto-
unlock e2e); existing tests updated for per-test limiter isolation.
2026-09-08 21:09:25 -05:00
poslop 9d75d2f80d password reveal: slash through eye icon while password hidden
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:55:25 -05:00
poslop 6b21f997b5 gitignore: exclude binary and live SQLite WAL files 2026-09-08 20:53:26 -05:00
poslop 103a7a6af5 UI batch 1: copy toast, protection/custom-url spacing, pw reveal, SVG icons, unlock redesign, bfcache result reset, search spinner (#19-#25,#27,#28,#32)
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:52:25 -05:00
poslop 1f162c4003 sweeper: release custom URLs on expiry and after 30-day reservation (#29)
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:50:40 -05:00
poslop 0bbe65ce05 capitalization: fix placeholder casing (#12)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-08 20:29:37 -05:00
poslop db17bd20b0 UI polish: radius tokens, layered shadows, pill selections, default filenames, live search, hidden result card, em dash removal
CI / test (push) Successful in 17s
CI / docker (push) Skipped
Fixes #6 #7 #8 #9 #10 #11 #13 #14 #15
2026-09-08 20:28:13 -05:00
poslop f542ce0407 new: split editor into title bar card, body card, standalone create button
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:01:02 -05:00
poslop c32a6e406d UI: scale up another 1.5x, widen history page container to fit
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:54:35 -05:00
poslop ec8f75d80b UI: scale everything up ~15% for readability on smaller devices
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:51:31 -05:00
poslop efa551c566 new: language auto-detect - /api/guess-language, refresh button, guess on paste
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:41:11 -05:00
poslop 3e0e64dc4f history: pager as its own floating pill below the list
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:34:44 -05:00
poslop 4a467ca999 history: search box under title, Search placeholder
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:32:41 -05:00
poslop 026d9b8c92 topbar: New/Public/Git labels, git opens in new tab
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:28:40 -05:00
poslop 4d98a92b09 history: fix column alignment - scope .row flex rule to new page, colgroup widths, nowrap+ellipsis cells
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:26:07 -05:00
poslop 603b807c51 history: entire row clickable, hover accent on slug
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:17:20 -05:00
poslop acafc13d20 test debian runner
CI / test (push) Successful in 1m12s
CI / docker (push) Skipped
2026-09-08 19:02:36 -05:00
poslop 176ef77737 CI: run jobs on debian-latest runner image
CI / test (push) Failing after 24s
CI / docker (push) Skipped
2026-09-08 18:57:27 -05:00
85 changed files with 5888 additions and 1437 deletions
+6 -2
View File
@@ -8,7 +8,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: [debian-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -26,7 +26,7 @@ jobs:
# build & push image only on tags (releases) # build & push image only on tags (releases)
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
needs: test needs: test
runs-on: ubuntu-latest runs-on: [debian-latest]
env: env:
# dind sidecar listens on tcp; job containers reach it via the docker bridge gateway # dind sidecar listens on tcp; job containers reach it via the docker bridge gateway
DOCKER_HOST: tcp://172.17.0.1:2375 DOCKER_HOST: tcp://172.17.0.1:2375
@@ -36,6 +36,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Docker CLI
run: |
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz && tar -xzf /tmp/docker.tgz -C /tmp && mv /tmp/docker/docker /usr/local/bin/docker && chmod +x /usr/local/bin/docker
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
+6 -4
View File
@@ -1,4 +1,6 @@
__pycache__/ /palette
node_modules/ palette.db
*.log palette.db-shm
.DS_Store palette.db-wal
admin-key
settings.json
+1 -1
View File
@@ -9,7 +9,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette ./cmd/palette
# ---- runtime stage ---- # ---- runtime stage ----
FROM alpine:3.20 FROM alpine:3.20
+60 -76
View File
@@ -1,109 +1,93 @@
# Palette # Palette
Fast, self-hosted pastebin with paste cans, password lock, expiry, custom URLs, and an API-first design. 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.
## Quick start ## 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
- 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
## Get Started
### Build from source
Requires Go 1.21+.
```bash ```bash
go build -o palette . go build -o palette .
./palette ./palette
# UI at http://localhost:8080 # open http://localhost:8080
``` ```
## Docker ### Docker
```bash ```bash
docker build -t palette . docker run -p 8080:8080 -v palette-data:/data git.archfox.org/poslop/palette
docker run -p 8080:8080 -v palette-data:/data palette
``` ```
The SQLite database lives in the `/data` volume inside the container.
## Screenshots
| | |
|---|---|
| ![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 ## Configuration
| Env var | Default | Description | | Setting | Default | Description |
|---|---|---| |---|---|---|
| `PALETTE_ADDR` | `:8080` | Listen address | | `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) | | `PALETTE_MAX_ITEM` | `26214400` | Max can item 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) |
### 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
```
## API ## API
### Create paste Create a paste with one call:
```bash ```bash
curl -X POST http://localhost:8080/api/pastes \ curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{"content": "print(hello)", "language": "python", "expires_in": "168h"}'
"content": "print(hello)",
"title": "my snippet",
"language": "python",
"expires_in": "168h",
"password": "optional",
"custom_slug": "optional",
"burn_after_read": false,
"visibility": "public"
}'
``` ```
Response includes `id`, `url`, `raw_url`, `api_url`, and a one-time `deletion_token`. Full API docs: [docs/API.md](docs/API.md). Design docs: [docs/design/](docs/design/) (currently: [client-side E2E encryption](docs/design/e2e-encryption.md), issue #39).
### Get paste ## Performance Notes
```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
```
### Raw content The history and Saved pages use client-side filtering: when you type in the
```bash search box, the UI fetches the most recent 100 pastes (`limit=100`, the API
curl http://localhost:8080/raw/{id} maximum) once per query and filters/sorts them in the browser. Pastes beyond
``` the newest 100 are not searched; a match count against the full total is still
shown. This keeps search instant without a server-side query. If large
### Soft delete instances need full search later, it will be a server-side endpoint (see
```bash issue #32).
curl -X DELETE http://localhost:8080/api/pastes/{id}
```
### Hard delete (requires deletion token)
```bash
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
```
### Public history
```bash
curl "http://localhost:8080/api/public?limit=25&offset=0"
```
### Create can (bundle 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"
```
### Get can + items
```bash
curl http://localhost:8080/api/cans/{id}
curl http://localhost:8080/api/cans/{id}/items/{item_id}
```
## 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 on first read.
## Web pages
- `/new` — create a paste
- `/history` — public paste history
- `/{id}` — view a paste
- `/unlock/{id}` — password gate for protected pastes
- `/raw/{id}` — raw content with original content type
## CI ## CI
-54
View File
@@ -1,54 +0,0 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"github.com/go-chi/chi/v5"
)
// genDeletionToken returns a 32-char url-safe random token
func genDeletionToken() string {
b := make([]byte, 24)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// maybeBurn marks a paste soft-deleted if burn_after_read is set.
// Returns true if this read consumed the paste.
func (s *Store) maybeBurn(row *PasteRow) bool {
if !row.BurnAfterRead {
return false
}
s.SoftDelete(row.ID)
return true
}
func deletionTokenEqual(stored, given string) bool {
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
}
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
// DELETE /api/pastes/{id}/redeem?token=...
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
token := r.URL.Query().Get("token")
if token == "" {
writeErr(w, 400, "token required")
return
}
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.DeletionToken.String == "" || !deletionTokenEqual(row.DeletionToken.String, token) {
writeErr(w, 403, "invalid token")
return
}
// hard delete: pastes table row goes away entirely
a.store.db.Exec(`DELETE FROM pastes WHERE id = ?`, row.ID)
writeJSON(w, 200, map[string]string{"status": "deleted"})
}
+48
View File
@@ -0,0 +1,48 @@
// Command palette is the palette pastebin server entrypoint: flag parsing
// and wiring of the store, API, and web packages.
package main
import (
"log"
"os"
"net/http"
"time"
"palette/internal/api"
"palette/internal/store"
"palette/internal/web"
)
func main() {
// #40: --reset-admin-key regenerates the admin key and exits.
if len(os.Args) > 1 && os.Args[1] == "--reset-admin-key" {
api.HandleResetAdminKey(api.EnvOr("PALETTE_DB", "palette.db"))
return
}
cfg := api.Config{
Addr: api.EnvOr("PALETTE_ADDR", ":8080"),
DBPath: api.EnvOr("PALETTE_DB", "palette.db"),
MaxTextBytes: int64(api.EnvIntOr("PALETTE_MAX_TEXT", 5*1024*1024)),
MaxItemBytes: int64(api.EnvIntOr("PALETTE_MAX_ITEM", 25*1024*1024)),
}
st, err := store.OpenStore(cfg.DBPath)
if err != nil {
log.Fatal(err)
}
adminKey, err := api.ResolveAdminKey(cfg.DBPath)
if err != nil {
log.Fatal(err)
}
ss := api.LoadSettingsStore(cfg.DBPath, cfg)
st.StartSweeper(time.Minute, ss.Get().CustomSlugReservationDays)
ui, err := web.New()
if err != nil {
log.Fatal(err)
}
srv := api.NewServer(st, cfg, ui, ss, adminKey)
log.Printf("palette listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
}
+109
View File
@@ -0,0 +1,109 @@
# 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 (requires the deletion token from the create response)
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/{id}
# ...or via query param; the creator browser (viewer cookie) may also delete without a token
curl -X DELETE "http://localhost:8080/api/pastes/{id}?token=TOKEN"
# hard delete immediately (requires the one-time deletion token)
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
```
## Lists
```bash
curl "http://localhost:8080/api/public?limit=25&offset=0" # public history
curl http://localhost:8080/api/mine # this browser's pastes (viewer cookie)
```
## Language detection
```bash
curl -X POST http://localhost:8080/api/guess-language \
-H "Content-Type: application/json" \
-d '{"content": "package main"}'
```
## Cans (bundles of items)
```bash
curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \
-F "expires_in=48h" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png" \
-F "files=@log.txt"
curl http://localhost:8080/api/cans/{id}
curl http://localhost:8080/api/cans/{id}/items/{item_id}
```
## Web pages
- `/new` — create a paste
- `/history` — public paste history
- `/mine` — pastes created from this browser
- `/{id}` — view a paste
- `/unlock/{id}` — password gate for protected pastes
- `/raw/{id}` — raw content with original content type
## Expiry and deletion
- Expired pastes are soft-deleted by a background sweeper (runs every minute).
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- Deletion tokens allow immediate hard delete.
- Burn-after-read pastes are soft-deleted once the read budget is exhausted.
+160
View File
@@ -0,0 +1,160 @@
# Design: Cookie-Based Preferences and Access Keys (#30)
Status: design note — no implementation yet.
Related: #37 (vwr viewer cookie), #34 (HMAC unlock cookie), #26 (creator auto-unlock), #36 (settings gear).
## Current cookie surface
| Cookie | Purpose | Lifetime | Flags today |
|---|---|---|---|
| `vwr` | Anonymous viewer id; scopes `/mine` history and burn-after-N per-viewer dedupe; client-sent `vwr` also authorizes delete | 1 year | `HttpOnly`, `SameSite=Lax`, `Path=/` |
| `pw_<id>` | Per-paste password unlock token = HMAC(paste id, PALETTE_UNLOCK_SECRET) | 1 hour | `HttpOnly`, `SameSite=Lax`, `Path=/` |
| `tok_<id>` | One-time deletion-token handoff after create | 60 s | `HttpOnly`, `SameSite=Lax`, `Path=/` |
The access-key feature is an extension of the `pw_<id>` pattern, not a new mechanism.
## Part 1: Preference storage
### What settings
Only settings the *creator* sets when writing a paste, so the "new paste" form
can pre-fill them:
- Default language (`lang`)
- Default expiry (`expires_in` / custom expiry)
- Burn-after-N-reads default
- Password-protect-by-default toggle (checkbox pre-checked; the password itself is never stored)
- Default visibility of the "raw" link, if such a toggle exists
- Collapsed/expanded state of the settings gear panel itself
Never stored in cookies: passwords, access keys for pastes the user hasn't
unlocked, deletion tokens (beyond the existing 60 s `tok_` handoff), anything
typed into the paste body or title fields (existing rule: auto-detect must not
overwrite user-typed content).
### One cookie, not many
A single `prefs` cookie holding a compact JSON object:
```
prefs={"lang":"go","exp":"1h","burn":0,"pw":1}
```
- One cookie avoids the browser per-domain cookie count (typically 50+ per
domain; Chrome 180) eating the budget that per-paste access-key cookies need.
- Per-paste cookies (`pw_<id>`) are inherently name-per-paste and cannot be
consolidated — that's the constraint that makes a single `prefs` cookie
mandatory rather than stylistic.
### Size limits
- RFC 6265: user agents SHOULD support at least 4096 bytes per cookie. Keep
`prefs` under 256 bytes of JSON — it holds a handful of short enum values.
- Server behavior: if the cookie is present but oversized/invalid JSON, ignore
it silently and serve defaults. Never reject a request over a bad preference
cookie.
- Validate on the server (allowlist of known values); a cookie is untrusted
input like any header.
### Flags
`HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000; Secure` (Secure once the
prod instance serves HTTPS — it will, behind the letsencrypt IngressRoute;
dev on plain HTTP needs the flag conditional on config).
Preferences are not sensitive, but `HttpOnly` costs nothing and keeps script
from mutating them; `SameSite=Lax` matches the existing cookies.
## Part 2: Access-key cookies
### Goal
"Remember unlocked pastes on this browser" — after entering a password (or
after creating a private paste), subsequent visits skip the unlock form. This
extends `pw_<id>` from a 1-hour session convenience to a durable capability.
### Design: extend `pw_<id>`, don't invent a new scheme
The token is already HMAC(paste id, PALETTE_UNLOCK_SECRET) — unforgeable and
per-paste (fix for the #34 bypass). Changes:
1. **Opt-in checkbox on the unlock form** ("remember on this browser") and a
matching checkbox/note at creation time. Default OFF. Non-consenting
visitors keep the current 1-hour cookie.
2. **Extended lifetime** when opted in: `Max-Age = min(paste expiry, 90 days)`.
The cookie must never outlive the paste — derive the cap from the paste's
`ExpiresAt` at unlock time. Burn-after-N pastes: cap short (e.g. 24 h),
since the paste may burn at any read.
3. **Name collision**: paste ids are fixed-length server-generated, so
`pw_<id>` names stay bounded (~40 bytes each). With the 50-cookies-per-
domain budget, cap remembered pastes at ~30: when minting the 31st, drop
the oldest expired-paste cookies server-side (server knows which ids are
expired/deleted; send expired `Set-Cookie` with `Max-Age=0` to reclaim).
4. **Delete authorization interplay**: today a client-sent `vwr` matching the
paste's ViewerID authorizes delete. Access-key cookies grant *read*
capability only. Do not let a `pw_<id>` cookie authorize deletion — that
would mean cookie theft escalates from "read a paste" to "destroy it".
Delete stays bound to `vwr` or the deletion token.
### Scoping
- Keep `Path=/` (paste URLs are `/{id}` at the root; per-paste `Path=/{id}`
would work but saves nothing and complicates cleanup).
- Per-paste scope via the cookie *name* is the existing, tested pattern —
no shared "access key ring" cookie. A consolidated `keys` cookie would
mean one stolen cookie exposes every remembered paste at once.
## Part 3: Security considerations (honest accounting)
- **XSS exfiltration**: `HttpOnly` prevents JS from *reading* the cookies, but
not from *using* them — an XSS payload can simply `fetch('/<paste-id>')` and
exfiltrate the content through the page the cookie unlocks. HttpOnly raises
the bar (drive-by script can't dump the jar to an attacker server in one
request), it does not make access-key cookies safe. This is a real
limitation, not a solved problem. Mitigations in order of value:
1. Fix the stored-XSS class at the source — #34 already allowlisted
content-types on `/raw`; the standing debt items (CSP, X-Frame-Options,
Referrer-Policy) directly reduce cookie-use exfiltration and should land
before or with this feature.
2. Keep access-key cookies opt-in, so the blast radius is bounded to users
who accepted the tradeoff.
- **Cookie theft = paste access**: anyone holding `pw_<id>` can read that
paste until the cookie or paste expires, from any machine. That is inherent
to capability cookies. Consequences accepted deliberately: pastes here are
ephemeral (1 min1 yr expiry), passwords are low-stakes share convenience,
and there are no user accounts to compromise. Document this in the UI copy
("stores unlock access on this browser").
- **Shared machines**: a remembered cookie defeats the password for the next
user of the browser. The opt-in checkbox with plain-language copy is the
mitigation; do not default it on.
- **Cookie tossing / fixation**: a subdomain attacker could try to force
cookies; palette is a single host, no untrusted subdomains. `SameSite=Lax`
blocks cross-site attachment of the cookies on form posts to unlock
endpoints.
- **Multi-instance / secret rotation**: tokens are HMACs under
`PALETTE_UNLOCK_SECRET`; rotating the secret silently invalidates all
remembered cookies (acceptable — next visit re-prompts). Both dev and prod
k3s instances need the same secret only if sharing a domain, which they do
not.
- **Preferences cookie**: not security-sensitive, but still validate/allowlist
server-side to avoid it becoming an injection sink into templates.
## Recommendation
Implement in two small, separately reviewable pieces:
1. **`prefs` cookie** (do first, low risk): single JSON cookie < 256 bytes,
server-validated allowlist, `HttpOnly; SameSite=Lax; Max-Age=1y`, drives
only form pre-fill. Ship with #36's settings gear.
2. **Extended `pw_<id>` opt-in** (second, security-sensitive): opt-in checkbox,
Max-Age capped by paste expiry (90-day ceiling, 24 h for burn pastes),
oldest-cookie eviction at ~30 pastes, no delete authorization from access
cookies, and land the CSP/X-Frame-Options hardening debt from #34 in the
same or preceding change. UI copy must disclose that the cookie preserves
paste access on the browser.
Rejected alternatives: single consolidated access-key cookie (aggregate theft
risk, and the per-domain cookie-count argument cuts the other way for keys —
consolidation maximizes what one stolen cookie unlocks); localStorage for
preferences (XSS-readable, no benefit over HttpOnly cookies here); server-side
accounts/session table (out of scope — Palette is deliberately anonymous).
+223
View File
@@ -0,0 +1,223 @@
# Design: Optional client-side E2E encryption for pastes and files
- **Issue:** #39
- **Status:** Design (no implementation in this PR)
- **Related docs:** [docs/API.md](../API.md)
## 1. Goals and non-goals
**Goals**
- Let any paste (or can item) be stored server-side as ciphertext only.
- Zero plaintext knowledge by the server: storage, logs, backups, DB dumps contain no readable content.
- Pure browser implementation using WebCrypto; no new server dependencies.
- Encrypted pastes must still work with expiry, hard/soft delete, deletion tokens, visibility, slugs, rate limits.
**Non-goals (v1)**
- Anonymous, account-less E2E; Palette stays server-trusting with browser cookies.
- Sharing via link fragments (`#key`) is optional sugar, not a required transport.
- Search *of encrypted content*, server-side language detection, or server-side highlighting on encrypted pastes — these are structurally impossible and out of scope (see §5).
- Signing, deniability, forward secrecy across pastes, PFS, post-quantum crypto.
**Threat model (explicit).** This protects against a *passive server compromise* — a DB dump, backup leak, or disk image of the server's SQLite file. It does **not** protect against:
- A fully malicious / compromised Palette server serving backdoored JavaScript: any JS-delivered crypto can be backdoored (key exfiltration via JS) regardless of primitives. This is the fundamental limit of a JS-in-browser E2E scheme.
- Malware on the viewer's device, or shoulder-surfing of the password.
- Traffic analysis, timing, or metadata (title, size, expiry, IP, viewer cookie).
- A attacker who compromises the server *while the creator's browser is open* and alters JS before encrypt.
Be explicit in user-facing copy: "encrypted at rest; the server cannot read your paste" is accurate — "the server can never see your paste" is not.
## 2. Crypto primitives and flow
### 2.1 Recommended parameters
| Parameter | Recommendation | Notes |
|---|---|---|
| Cipher | **AES-256-GCM** | `AES-GCM` with a 256-bit key, per-paste random 96-bit IV/nonce. WebCrypto built-in, hardware-accelerated, authenticated. |
| KDF | **PBKDF2-HMAC-SHA-256** | 600,000 iterations (OWASP 2023+ recommendation), 16-byte random salt. |
| Argon2id | **Not in v1** | WebCrypto has no Argon2id; a JS/WASM Argon2 implementation is an extra supply-chain dependency and is an asymmetric liability: a script the server could swap can't be load-bearing for security anyway. Add later via `argon2id` WASM with SRI pinning + CSP (`script-src 'self'`) if needed. |
| Salt | 16 random bytes per paste, stored in the clear alongside ciphertext | Unique per paste, never reused. |
| IV | 12 random bytes per encryption | With ~2^32 encryptions per key this is negligible; each paste has its own key anyway. |
| Key check value | See §2.2 | Catches wrong passwords without a server round-trip and prevents trash writes. |
### 2.2 Flow (create)
1. User checks "Encrypt" and enters an encryption passphrase (distinct from any access password) in `/new`.
2. Browser generates `salt` (16 B) and `iv` (12 B) via `crypto.getRandomValues`.
3. `crypto.subtle.importKey("raw", passphrase, "PBKDF2", false, ["deriveKey"])`
`crypto.subtle.deriveKey(PBKDF2-SHA-256, 600k iterations, salt, {name:"AES-GCM", length:256}, false, ["encrypt","decrypt"])`.
4. Generate a 32-byte random **DEK** (`crypto.getRandomValues(32)`).
5. Content encryption key check: `iv_ckv`, `encrypted_content = AES-GCM-256(DEK, iv, content)`.
6. **Key check value (KCV):** compute `AES-GCM-DEK(random 16 bytes)` — a small token encrypted *under the DEK*, stored as `key_check` blob. This is decrypted with the derived key; on wrong password GCM auth fails and the client can show "wrong key" without asking the server to burn a read.
7. Wrap the DEK with the KEK: `wrapped_dek = AES-GCM(KEK, iv_wrap, dek)`.
8. The stored envelope format:
```
{
v: 1, kdf: "PBKDF2-SHA256", iterations: 600000, salt_b64, iv_b64,
kdf_salt_b64, wrap_iv_b64, wrapped_dek_b64, key_check_b64, ciphertext_b64
```
The `v` field allows migrating to Argon2id later without a breaking change.
8. POST the envelope (base64) as `content`, with an `encryption` metadata object alongside (see §3.1).
### 2.3 Flow (view)
1. User provides the passphrase via form field, or the key arrives in the URL `#fragment`. The encryption passphrase is a separate field from any access password.
2. Fetch `/api/pastes/{id}` (with access password in the usual field if the paste is also password-gated).
3. Derive KEK from passphrase+salt, unwrap DEK via key_check / unwrap step.
4. Decrypt content with the DEK; on `OperationError` → "wrong passphrase" UI state (retries are client-side only; no re-fetch, so no extra burn-after-read charge).
5. Language detection happens client-side (e.g. highlight.js auto-detect) on the decrypted plaintext.
### §2.4 File and can items
Files in cans: encrypt each file with its own DEK and store the same envelope. Cans' `json_items` content fields each carry their own envelope. Files keep their mime type in cleartext metadata; only the bytes are encrypted. The can's title stays plaintext (unless the whole can is encrypted, v2).
## 3. API shapes
### 3.1 Create request
Existing fields unchanged. New optional `encryption` object:
```json
POST /api/pastes
{
"content": "<base64 envelope>",
"encryption": {"v": 1, "kdf": "PBKDF2-SHA256", "iterations": 600000,
"salt": "b64", "iv": "b64", "key_check": "b64"}
}
```
`encryption` is non-secret KDF metadata for UI display; the server treats `content` as opaque bytes and MUST NOT inspect it for encrypted pastes (no detection, no highlighting prep, no search indexing) — enforced where content is written, not per-handler.
The full envelope can also just live inside `content` (server-opaque); the `encryption` object carries only non-secret KDF metadata the list views need (e.g. to show a 🔒 icon).
### 3.2 Create response
Unchanged shape: `id`, `url`, `raw_url`, `api_url`, and the one-time `deletion_token` documented in docs/API.md.
### 3.3 Get response
`GET /api/pastes/{id}` response gains:
```json
{
"id": "abc123",
"content": "BASE64_ENVELOPE",
"encryption": {"v":1, "kdf": "PBKDF2-SHA256", "iterations": 601570, "salt": "b64", "iv": "base64", "key_check": "b64"},
"reads_remaining": null
}
```
`language` is `"encrypted"` or `null` so clients don't run detection on ciphertext. `raw_url` also serves the envelope; the `/{id}` page ships it to the browser, which decrypts in place.
### 3.4 Raw endpoint
`GET /raw/{id}` returns the envelope as `application/octet-stream` with a suggested filename like `{id}.e2e.txt` and `Content-Disposition: attachment`. This is deliberate: a "download encrypted blob" is what a non-browser client can do with it anyway.
### 3.5 List views / mine / public
List endpoints return `has_encryption: true` instead of content; show a lock icon. Do not include ciphertext in list responses (size, and no reason to ship ciphertext to every viewer's list view) — `GET /api/pastes/{id}` remains the only endpoint that returns the envelope.
`/api/mine` (creator's own browser) may include the envelope for convenience; `/api/public` returns metadata only.
`/api/guess-language` rejects encrypted content with `400 "content is client-encrypted"` — detection needs plaintext; clients detect after decrypting.
Delete, redeem, rate limits, expiry, sweeper, deletion tokens, visibility, slugs, and can CRUD are unchanged — the server never inspects content for these, so opaque content is a no-op path.
## 4. Interplay with existing features
| Feature | Impact | Mitigation |
| Burn-after-read | Budget is charged on fetch, exactly as today; the server cannot know whether decryption succeeded, so a viewer fetching with the wrong key burns a read they can't use. | Decrypt retries are client-side, so only the first fetch charges the budget. Clear UX copy. |
| Password-protected + encrypted | Both can coexist and are independent: the access password is an HTTP 401 gate; the encryption passphrase never leaves the browser. If both are set, all three secrets are needed (URL + access password + passphrase). Warn if the user enters the same value in both fields. |
| Encryption-only pastes | Supported with no access password: URL + passphrase (or fragment key). Default is passphrase; fragment key is opt-in with a warning. |
| Search | Structurally impossible over ciphertext. Server search just skips encrypted pastes; client-side search within a single decrypted paste works fine. No global encrypted-content search — accept the loss, document it. |
| Language detection / highlighting | Server-side detection/highlighting impossible; returns `language: null`. Client-side detection via highlight.js auto-detect on decrypted plaintext (client already loads it for password gate pages). |
| Cans/files | Per-item envelopes (own DEK each), per §2.4. | Consider a can-level KEK (one passphrase unlocks all items). |
| Expiry/sweeper/delete/redeem | Unchanged — server never inspects content for these. |
| List views (`/api/public`, `/api/mine`) | Additive `has_encryption: true` flag; list responses do not include ciphertext (`/api/mine` may include the envelope for the creator's own convenience). |
| guess-language endpoint | Reject with 400. |
| Fork / edit | Re-encryption needs the passphrase in the browser; v1 disables forking encrypted pastes. | Document the limitation. |
## 5. What breaks, stated plainly
- **Search across encrypted pastes: impossible.** Accept the loss. (If ever needed, client-side index in IndexedDB for the creator's own pastes — v2+.)
IndexedDB only helps the creator, not other viewers; still not global search. Accept the loss.
- **Server-side language detection and highlighting: impossible.** Client-side detection on decrypted plaintext. Server returns `language: null` and the client detects.
- **Burn-after-read is weakened in one specific way:** the budget is counted on fetch, not on successful decryption. A viewer who fetches but can't decrypt (wrong/lost key) burns a read they can't use. Mitigations documented in §4 table. The server can still count fetches (which is what burn-after-read actually is, even today: it counts fetches, not "reads" in any content-aware sense). So burn-after-read still works — it counts fetches — it's just that a failed decryption still consumes budget. This is acceptable and just needs UX copy. Optionally: don't decrement on failed decryption is *not possible* the server can't tell, so it's fetch-based, period. (It already is today.)
- **Raw endpoint semantics change:** `/raw/{id}` can no longer serve readable raw text. It serves the ciphertext envelope. Scripts that curl raw pastes will get base64 envelope instead of text. Document as a breaking-ish change for encrypted pastes only; unencrypted pastes unchanged.
- **Existing /api/mine, /api/public list shapes gain a flag** (additive, non-breaking).
- **Copy-to-clipboard of decrypted text stays client-side**, fine. "Copy raw" on an encrypted paste copies the envelope — label it clearly.
## 5. UX for key sharing
### 5.1 Three sharing modes
| Mode | What's shared | Security level | Use case |
|---|---| malformed JSON / wrong key | | |
| Mode | What's shared | Strength | Use case |
|---|---|---|---|
| **Passphrase** (default) | URL + passphrase out-of-band (Signal etc.) | Good — two channels | Team snippets, sensitive configs |
| **Passphrase + access password** | URL + access password (401 gate) + passphrase | Strong — two secrets, two channels | Highest sensitivity |
| **Random key in `#fragment`** | URL containing `#key=<b64>` | Weak — single channel; anyone with the full URL has both parts. Copy/paste into chat defeats it entirely. | One-click convenience sharing |
Browsers never transmit `#` fragments to servers; still set `Referrer-Policy: no-referrer` site-wide and offer separate copy buttons for URL and key. Key-in-fragment ships with a warning and stays opt-in.
### 5.2 Create page (`/new`) UX
- "Encrypt content" toggle → reveals passphrase field + strength meter + generate-random-key button.
- When encrypting, hide the server-side language dropdown; the client detects language after decryption.
- Two separate inputs with distinct labels: "Access password (checked by the server, 401 gate)" and "Encryption passphrase (never leaves your browser)". If both hold the same value, warn.
### 6.2 View page (`/{id}`) UX
- If `encryption.kdf` is present → show key entry UI (after the access-password 401 gate, if that also applies).
- After decrypt: normal render pipeline, language detected client-side.
- "Wrong passphrase" retries never re-fetch, so they never burn extra reads.
## 7. Backwards compatibility and migration
- Additive JSON fields only; unencrypted pastes behave identically. No schema changes (envelope is stored in the existing content column/TEXT; verify column size allows envelope overhead (~2× base64 + ~200 B header).
- Server-side validation of encrypted pastes: only structural checks (base64 decodes, size ≤ max bytes). No crypto in the server.
**Server implementation cost is genuinely small** (est. 2-4 days): pass through content untouched, add `encryption` metadata column or embed in content, skip detection/indexing when `encryption` is present, list flag. The server never does crypto. All crypto is client-side JS (~150-300 lines, no build-step change if using WebCrypto alone).
**Argon2id later:** add `kdf: "argon2id"` to the envelope `v: 1` (m=64 MiB, t=3, p=1) via a SRI-pinned WASM module, with CSP `script-src 'self'` + SRI on the script tag. Envelope `v` field already allows this.
## 8. Recommendation
**Build it, as an opt-in checkbox, passphrase mode only in v1.**
- Server cost is small (pass-through + skip detection/indexing + list flag), client cost moderate (WebCrypto only, no new deps).
- It closes the biggest real-world risk for a public pastebin: a DB/backup leak exposing every paste ever written.
- Skip Argon2id in v1; envelope `v` field provides a migration path.
- Key-in-fragment mode: build the plumbing (fragment parsing) but hide behind "advanced"; default remains passphrase.
**Do not build:** server-side search over encrypted content, server-side highlighting of encrypted content, decrypt-on-server "preview" mode, or any server-side crypto.
## 9. Open questions
1. Size limits: base64 expansion (~4/3×) plus ~200 B envelope overhead; the existing max-bytes / 413 limit applies to the envelope bytes the server stores. Do not compress before encrypting (CRIME-style weaknesses).
2. Fork/edit of encrypted pastes: disabled in v1, revisit.
3. Should `/api/mine` include the full envelope in list view? Leaning yes (creator's own browser can decrypt); note the larger payload.
4. Should there be a "verify passphrase" second field at create time (type-twice), or rely on the KCV check at view time? KCV at view time suffices; type-twice adds friction at create. Rely on KCV, skip type-twice.
5. CSP/Referrer-Policy hardening: `Referrer-Policy: no-referrer` site-wide is worth doing regardless of this feature (it also benefits unencrypted pastes).
6. Cans: per-item DEKs wrapped by a single can-level KEK (one passphrase unlocks all items) — better UX, slightly more envelope design work. Defer detail to implementation.
## 10. Alternatives considered
| Alternative | Why not in v1 |
|---|---|
| Argon2id via WASM in v1 | Extra JS dependency the server could swap → can't be load-bearing; PBKDF2-600k is adequate for a pastebin. Defer. |
| Server holds half a key (2-of-2 with server-held share) | Re-introduces server trust; defeats the purpose. |
| age-format envelopes | Nice CLI interop but no WebCrypto-native support; adds a JS dependency. Defer. |
| PGP / S-MIME | Poor browser UX; heavy dependencies. |
| Server-side encryption with server-held keys | Not E2E; that's "encrypted at rest", already covered by disk-level encryption. |
| PrivateBin-style fragment key only | Single-channel sharing is a footgun; keep passphrase as default. |
| libsodium / tweetnacl | Solid but unnecessary; WebCrypto covers AES-GCM + PBKDF2 natively. |
## 11. References
- OWASP Password Storage Cheat Sheet (PBKDF2 guidance): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- MDN WebCrypto: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- PrivateBin (prior art for fragment-key sharing): https://privatebin.info
- 0bin, Hemmelig — other pastebin/secret E2E prior art.
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+2
View File
@@ -4,12 +4,14 @@ go 1.27.1
require ( require (
github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/chi/v5 v5.3.2
github.com/go-enry/go-enry/v2 v2.9.6
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.39.0
modernc.org/sqlite v1.58.0 modernc.org/sqlite v1.58.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
+21
View File
@@ -1,7 +1,14 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs=
github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -12,8 +19,17 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
@@ -24,6 +40,11 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"palette/internal/store"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// #40: admin endpoint with an install-time key. The key is read from
// PALETTE_ADMIN_KEY when set; otherwise a 32-char random hex key is generated
// and persisted to <db-dir>/admin-key (0600) so it survives restarts.
// Settings holds the runtime-tunable values the admin API exposes. The list
// is intentionally small and extensible: add a field + JSON tag, wire it into
// the consumer, and it round-trips through GET/POST /admin/api/settings.
type Settings struct {
RateLimitBurst float64 `json:"rate_limit_burst"`
RateLimitPerMinute float64 `json:"rate_limit_per_minute"`
MaxContentBytes int64 `json:"max_content_bytes"`
DefaultExpiry string `json:"default_expiry"`
CustomSlugReservationDays int `json:"custom_slug_reservation_days"`
BurnViewerWindowMinutes int `json:"burn_viewer_window_minutes"`
}
func defaultSettings(cfg Config) Settings {
return Settings{
RateLimitBurst: 5,
RateLimitPerMinute: 60, // 1 req/sec refill
MaxContentBytes: cfg.MaxTextBytes,
DefaultExpiry: "", // no default: pastes are permanent unless expires_in given
CustomSlugReservationDays: 30,
BurnViewerWindowMinutes: 15,
}
}
// settingsStore keeps the current settings in memory (mutex-guarded) and
// persists them as JSON to <db-dir>/settings.json.
type settingsStore struct {
mu sync.RWMutex
cur Settings
path string
}
// LoadSettingsStore loads (or initializes) the settings store.
func LoadSettingsStore(dbPath string, cfg Config) *settingsStore {
p := filepath.Join(filepath.Dir(dbPath), "settings.json")
ss := &settingsStore{cur: defaultSettings(cfg), path: p}
if b, err := os.ReadFile(p); err == nil {
var s Settings
if json.Unmarshal(b, &s) == nil {
// merge over defaults so newly added fields keep sane values
def := defaultSettings(cfg)
if s.RateLimitBurst > 0 {
def.RateLimitBurst = s.RateLimitBurst
}
if s.RateLimitPerMinute > 0 {
def.RateLimitPerMinute = s.RateLimitPerMinute
}
if s.MaxContentBytes > 0 {
def.MaxContentBytes = s.MaxContentBytes
}
if s.DefaultExpiry != "" {
def.DefaultExpiry = s.DefaultExpiry
}
if s.CustomSlugReservationDays > 0 {
def.CustomSlugReservationDays = s.CustomSlugReservationDays
}
if s.BurnViewerWindowMinutes > 0 {
def.BurnViewerWindowMinutes = s.BurnViewerWindowMinutes
}
ss.cur = def
}
}
return ss
}
func (ss *settingsStore) get() Settings {
ss.mu.RLock()
defer ss.mu.RUnlock()
return ss.cur
}
func (ss *settingsStore) set(s Settings) error {
if s.RateLimitBurst <= 0 || s.RateLimitPerMinute <= 0 || s.MaxContentBytes <= 0 ||
s.CustomSlugReservationDays <= 0 || s.BurnViewerWindowMinutes <= 0 {
return fmt.Errorf("all numeric settings must be positive")
}
if s.DefaultExpiry != "" {
d, err := time.ParseDuration(s.DefaultExpiry)
if err != nil || !store.ValidExpiry(d) {
return fmt.Errorf("default_expiry must be a duration between 1 minute and 1 year (or empty)")
}
}
ss.mu.Lock()
defer ss.mu.Unlock()
b, _ := json.Marshal(s)
if err := os.WriteFile(ss.path, b, 0600); err != nil {
return err
}
ss.cur = s
return nil
}
// resetAdminKeyFile deletes the persisted admin key file (if any) and returns
// the path so callers can regenerate. Used by --reset-admin-key (#40).
// ResetAdminKeyFile deletes the persisted admin key file (if any).
func ResetAdminKeyFile(dbPath string) string {
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
os.Remove(p)
return p
}
// resolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
// persisted key file is reused; else a new 32-char hex key is generated and
// persisted with 0600 perms.
// ResolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
// persisted key file is reused; else a new 32-char hex key is generated and
// persisted with 0600 perms.
func ResolveAdminKey(dbPath string) (string, error) {
if v := os.Getenv("PALETTE_ADMIN_KEY"); v != "" {
return v, nil
}
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
if b, err := os.ReadFile(p); err == nil && len(strings.TrimSpace(string(b))) >= 16 {
return strings.TrimSpace(string(b)), nil
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
key := hex.EncodeToString(b)
if err := os.WriteFile(p, []byte(key+"\n"), 0600); err != nil {
return "", err
}
log.Printf("generated admin key, persisted to %s", p)
return key, nil
}
// handleResetAdminKey implements the --reset-admin-key flag: delete the key
// file, generate a fresh key, print it.
// HandleResetAdminKey implements the --reset-admin-key flag: delete the key
// file, generate a fresh key, print it.
func HandleResetAdminKey(dbPath string) {
p := ResetAdminKeyFile(dbPath)
key, err := ResolveAdminKey(dbPath)
if err != nil {
log.Fatalf("reset admin key: %v", err)
}
fmt.Printf("admin key reset; new key written to %s:\n%s\n", p, key)
}
// adminKeyOK reports whether the request carries the correct admin key via
// X-Admin-Key header or ?key=. Constant-time compare; failures and successes
// are both logged (#40).
func (a *apiServer) adminKeyOK(r *http.Request, key string) bool {
given := r.Header.Get("X-Admin-Key")
if given == "" {
given = r.URL.Query().Get("key")
}
return subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1
}
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")
return
}
log.Printf("admin auth OK: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
next(w, r)
}
}
func (a *apiServer) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, a.settings.get())
}
func (a *apiServer) handleAdminPostSettings(w http.ResponseWriter, r *http.Request) {
var s Settings
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
writeErr(w, 400, "invalid json body")
return
}
if err := a.settings.set(s); err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, a.settings.get())
}
// Get returns the current settings (exported for cmd wiring).
func (ss *settingsStore) Get() Settings { return ss.get() }
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// newTestSettingsStore builds an in-memory settings store with a temp file.
func NewTestSettingsStore(t *testing.T, cfg Config) *settingsStore {
t.Helper()
dir := t.TempDir()
ss := LoadSettingsStore(filepath.Join(dir, "palette.db"), cfg)
// point persistence at a temp path (dir(dbPath) == dir)
return ss
}
func TestAdminAuth(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/admin/api/settings", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("no key: expected 401, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/admin/api/settings", nil)
req.Header.Set("X-Admin-Key", "wrong-key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("wrong key: expected 401, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/admin/api/settings?key=test-admin-key", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("query key: expected 200, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/admin/api/settings", nil)
req.Header.Set("X-Admin-Key", "test-admin-key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("header key: expected 200, got %d", rec.Code)
}
// HTML page itself is open (key entered via form)
req = httptest.NewRequest("GET", "/admin", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("admin page: expected 200, got %d", rec.Code)
}
}
func TestAdminEnvKeyPrecedence(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "palette.db")
t.Setenv("PALETTE_ADMIN_KEY", "envkey1234567890abcdef")
key, err := ResolveAdminKey(dbPath)
if err != nil {
t.Fatal(err)
}
if key != "envkey1234567890abcdef" {
t.Fatalf("env key not used: %q", key)
}
if _, err := os.Stat(filepath.Join(dir, "admin-key")); !os.IsNotExist(err) {
t.Fatal("env key should not create a key file")
}
// unset env: file takes over
os.Unsetenv("PALETTE_ADMIN_KEY")
key2, err := ResolveAdminKey(dbPath)
if err != nil {
t.Fatal(err)
}
if len(key2) != 32 {
t.Fatalf("generated key should be 32 hex chars, got %d", len(key2))
}
if fi, err := os.Stat(filepath.Join(dir, "admin-key")); err != nil || fi.Mode().Perm() != 0600 {
t.Fatalf("admin-key file perms: %v err %v", fi, err)
}
// reuse on subsequent boots
key3, _ := ResolveAdminKey(dbPath)
if key3 != key2 {
t.Fatal("persisted key not reused")
}
}
func TestAdminSettingsRoundTrip(t *testing.T) {
s := testServer(t)
h := s.routes()
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(body))
req.Header.Set("X-Admin-Key", "test-admin-key")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
rec := post(`{"rate_limit_burst": 9, "rate_limit_per_minute": 120, "max_content_bytes": 1024, "default_expiry": "1h", "custom_slug_reservation_days": 10, "burn_viewer_window_minutes": 7}`)
if rec.Code != 200 {
t.Fatalf("post settings: %d %s", rec.Code, rec.Body.String())
}
got := s.settings.get()
if got.RateLimitBurst != 9 || got.RateLimitPerMinute != 120 || got.MaxContentBytes != 1024 ||
got.DefaultExpiry != "1h" || got.CustomSlugReservationDays != 10 || got.BurnViewerWindowMinutes != 7 {
t.Fatalf("settings not applied: %+v", got)
}
// persisted to disk
b, err := os.ReadFile(s.settings.path)
if err != nil {
t.Fatal(err)
}
var persisted Settings
if err := json.Unmarshal(b, &persisted); err != nil {
t.Fatal(err)
}
if persisted.BurnViewerWindowMinutes != 7 {
t.Fatalf("persisted settings wrong: %+v", persisted)
}
// invalid rejected
if rec := post(`{"rate_limit_burst": -1}`); rec.Code != 400 {
t.Fatalf("invalid settings: expected 400, got %d", rec.Code)
}
if rec := post(`{"rate_limit_burst": 5, "rate_limit_per_minute": 60, "max_content_bytes": 1024, "default_expiry": "bogus", "custom_slug_reservation_days": 10, "burn_viewer_window_minutes": 5}`); rec.Code != 400 {
t.Fatalf("bad expiry: expected 400, got %d", rec.Code)
}
// settings actually consumed: default expiry applied on create
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x"}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ExpiresAt *int64 `json:"expires_at"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ExpiresAt == nil {
t.Fatal("default expiry not applied to new paste")
}
}
func TestAdminResetKey(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "palette.db")
os.Unsetenv("PALETTE_ADMIN_KEY")
key1, _ := ResolveAdminKey(dbPath)
// direct invocation of the reset behavior
ResetAdminKeyFile(dbPath)
key2, _ := ResolveAdminKey(dbPath)
if key1 == key2 {
t.Fatal("reset did not regenerate key")
}
}
+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")
}
}
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"palette/internal/store"
)
// burnViewerWindow returns the admin-tunable per-viewer dedupe window
// (#40), falling back to the 15-minute default from #49.
func (a *apiServer) burnViewerWindow() int {
if a.settings != nil {
if m := a.settings.get().BurnViewerWindowMinutes; m > 0 {
return m
}
}
return 15
}
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
// DELETE /api/pastes/{id}/redeem?token=...
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
token := r.URL.Query().Get("token")
if token == "" {
writeErr(w, 400, "token required")
return
}
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.DeletionToken.String == "" || !store.DeletionTokenEqual(row.DeletionToken.String, token) {
writeErr(w, 403, "invalid token")
return
}
// hard delete: pastes table row goes away entirely
a.store.HardDelete(row.ID)
writeJSON(w, 200, map[string]string{"status": "deleted"})
}
+2 -2
View File
@@ -1,4 +1,4 @@
package main package api
import ( import (
"encoding/json" "encoding/json"
@@ -71,7 +71,7 @@ func TestDeletionTokenRedeem(t *testing.T) {
// gone for good: even soft-deleted lookup returns nothing, and row count is 0 // gone for good: even soft-deleted lookup returns nothing, and row count is 0
var n int var n int
s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&n) n = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
if n != 0 { if n != 0 {
t.Fatal("row still exists after redeem") t.Fatal("row still exists after redeem")
} }
+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)
}
}
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"palette/internal/store"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type anyHandler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
// createBurnReads creates a burn-after-N-reads paste and returns its id.
func createBurnReads(t *testing.T, h anyHandler, reads int) string {
t.Helper()
body, _ := json.Marshal(map[string]any{"content": "limited", "burn_after_read": true, "burn_after_reads": reads})
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create burn_after_reads=%d: %d %s", reads, rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("no id in create response")
}
return created.ID
}
func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
if viewer != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: viewer})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// viewer A: ok (read 1)
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1 (viewer A): %d %s", rec.Code, rec.Body.String())
}
// viewer B: ok (read 2)
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
t.Fatalf("read 2 (viewer B): %d %s", rec.Code, rec.Body.String())
}
// viewer C: burned -> 404
if rec := getWithCookie(t, h, id, "ccc"); rec.Code != 404 {
t.Fatalf("read 3 expected 404, got %d", rec.Code)
}
}
func TestBurnReadsSameViewerWithinWindowNoDecrement(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// same viewer reads twice within the window: second is deduped
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("deduped re-read expected 200, got %d", rec.Code)
}
// another viewer still gets read 2 (budget not consumed by re-reads)
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
t.Fatalf("read 2: %d", rec.Code)
}
}
func TestBurnReadsWindowExpiryRecounts(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
base := time.Now()
store.TimeNow = func() time.Time { return base }
t.Cleanup(func() { store.TimeNow = time.Now })
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
// 10 minutes later: still within window, deduped
store.TimeNow = func() time.Time { return base.Add(10 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read within window: %d", rec.Code)
}
// 20 minutes after first read: window expired, counts as read 2
store.TimeNow = func() time.Time { return base.Add(20 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read after window expected 200, got %d", rec.Code)
}
// budget exhausted -> 404 even for the same viewer
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 404 {
t.Fatalf("after budget expected 404, got %d", rec.Code)
}
}
func TestBurnReadsDefaultOne(t *testing.T) {
s := testServer(t)
h := s.routes()
// burn_after_read without burn_after_reads defaults to 1 read
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"one","burn_after_read":true}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
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 != 404 {
t.Fatalf("read 2 expected 404, got %d", rec.Code)
}
}
func TestBurnReadsPageViewCounts(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// HTML page view counts as a read too (documented decision)
req := httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page view 1: %d", rec.Code)
}
// re-view within window: deduped
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page re-view: %d", rec.Code)
}
// distinct viewer: read 2, page renders with reads remaining
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "bbb"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page view 2: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Reads left") {
t.Fatal("stats pill missing 'Reads left'")
}
// third viewer: burned
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "ccc"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("page view 3 expected 404, got %d", rec.Code)
}
}
+25 -54
View File
@@ -1,11 +1,11 @@
package main package api
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"palette/internal/store"
"strings" "strings"
"time" "time"
@@ -40,12 +40,18 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "invalid expires_in") writeErr(w, 400, "invalid expires_in")
return return
} }
// #60: clamp at the API boundary like the pastes API does -
// reject zero/negative and durations past the 1-year UI cap.
if !store.ValidExpiry(d) {
writeErr(w, 400, "expires_in must be between 1 minute and 1 year")
return
}
t := now + int64(d.Seconds()) t := now + int64(d.Seconds())
expiresAt = &t expiresAt = &t
} }
var pwHash *string var pwHash *string
if password != "" { if password != "" {
h, err := hashPassword(password) h, err := store.Argon2IDHash(password)
if err != nil { if err != nil {
writeErr(w, 500, "hash error") writeErr(w, 500, "hash error")
return return
@@ -53,9 +59,8 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
pwHash = &h pwHash = &h
} }
canID := genSlug(8) canID := store.GenSlug(8)
_, err := a.store.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at) err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
VALUES (?,?,?,?,?,?,?)`, canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
if err != nil { if err != nil {
writeErr(w, 500, "db error") writeErr(w, 500, "db error")
return return
@@ -76,7 +81,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
return return
} }
lang := it["language"] lang := it["language"]
if err := a.store.insertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil { if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
writeErr(w, 500, "db error") writeErr(w, 500, "db error")
return return
} }
@@ -104,7 +109,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
return return
} }
contentStr := string(content) contentStr := string(content)
if err := a.store.insertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil { if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
writeErr(w, 500, "db error") writeErr(w, 500, "db error")
return return
} }
@@ -114,7 +119,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
} }
if itemCount == 0 { if itemCount == 0 {
a.store.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID) a.store.DeleteCan(canID)
writeErr(w, 400, "can needs at least one item (files or json_items)") writeErr(w, 400, "can needs at least one item (files or json_items)")
return return
} }
@@ -144,46 +149,6 @@ func detectContentType(name string, content []byte) string {
return "text/plain" return "text/plain"
} }
func (s *Store) insertCanItem(canID, title, content, contentType string, language, expiresAt *string, binary *string, now int64) error {
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
_, err := s.db.Exec(`INSERT INTO pastes
(id, content, content_type, language, title, visibility, can_id, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
_ = expiresAt
_ = binary
return err
}
func (s *Store) GetCan(id string) (*CanRow, error) {
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at
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)
if err == sql.ErrNoRows {
return nil, nil
}
return &c, err
}
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PasteRow
for rows.Next() {
var r PasteRow
if err := rows.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) { func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id) can, err := a.store.GetCan(id)
@@ -204,7 +169,7 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
if pw == "" { if pw == "" {
pw = r.URL.Query().Get("password") pw = r.URL.Query().Get("password")
} }
if pw == "" || !checkPassword(can.PasswordHash.String, pw) { if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required") writeErr(w, 401, "password required")
return return
} }
@@ -224,12 +189,12 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
metas := make([]itemMeta, 0, len(items)) metas := make([]itemMeta, 0, len(items))
for _, it := range items { for _, it := range items {
metas = append(metas, itemMeta{ metas = append(metas, itemMeta{
ID: it.ID, Title: nullStrPtr(it.Title), ContentType: it.ContentType, ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
Size: len(it.Content), URL: "/api/pastes/" + it.ID, Size: len(it.Content), URL: "/api/pastes/" + it.ID,
}) })
} }
writeJSON(w, 200, map[string]any{ writeJSON(w, 200, map[string]any{
"id": can.ID, "title": nullStrPtr(can.Title), "visibility": can.Visibility, "id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
"created_at": can.CreatedAt, "items": metas, "created_at": can.CreatedAt, "items": metas,
}) })
} }
@@ -253,11 +218,17 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
if pw == "" { if pw == "" {
pw = r.URL.Query().Get("password") pw = r.URL.Query().Get("password")
} }
if pw == "" || !checkPassword(can.PasswordHash.String, pw) { if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required") writeErr(w, 401, "password required")
return return
} }
} }
w.Header().Set("Content-Type", row.ContentType) // #34: same content-type guard as /raw — never serve active content types.
ct := row.ContentType
if !safeRawContentType(ct) {
ct = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write([]byte(row.Content)) w.Write([]byte(row.Content))
} }
+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())
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package main package api
import ( import (
"bytes" "bytes"
@@ -1,6 +1,8 @@
package main package api
import ( import (
"palette/internal/store"
"encoding/json" "encoding/json"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -47,8 +49,9 @@ func TestCustomSlugValidation(t *testing.T) {
{"bad slug", `{"content":"x","custom_slug":"has space"}`, 400}, {"bad slug", `{"content":"x","custom_slug":"has space"}`, 400},
{"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine {"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine
} }
for _, c := range cases { for i, c := range cases {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body))
req.RemoteAddr = "10.7.1." + string(rune('1'+i)) + ":1000" // avoid rate-limit bucket sharing
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != c.wantCode { if rec.Code != c.wantCode {
@@ -60,7 +63,7 @@ func TestCustomSlugValidation(t *testing.T) {
func TestSlugCollisionWithAutoID(t *testing.T) { func TestSlugCollisionWithAutoID(t *testing.T) {
s := testServer(t) s := testServer(t)
// manually insert a paste, then try to claim its auto ID as a custom slug // manually insert a paste, then try to claim its auto ID as a custom slug
p, err := s.store.CreatePaste(&Paste{Content: "auto"}) p, err := s.store.CreatePaste(&store.Paste{Content: "auto"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+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)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"encoding/json"
"net/http"
"palette/internal/lang"
)
// handleGuessLang serves POST /api/guess-language.
func (a *apiServer) handleGuessLang(w http.ResponseWriter, r *http.Request) {
setRateLimitHeaders(w, 1, 5)
if !rateLimitGuess(r) {
writeRateLimited(w, 1)
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json body")
return
}
l := lang.GuessLang(req.Content)
writeJSON(w, http.StatusOK, map[string]any{"language": l})
}
+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)
}
}
+77 -13
View File
@@ -1,6 +1,9 @@
package main package api
import ( import (
"palette/internal/store"
"palette/internal/web"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -11,11 +14,20 @@ import (
func testServer(t *testing.T) *apiServer { func testServer(t *testing.T) *apiServer {
t.Helper() t.Helper()
store, err := OpenStore(":memory:") globalLimiter = newLimiter() // fresh buckets per test
ui, err := web.New()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return &apiServer{store: store, cfg: Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}} st, err := store.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}
ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
} }
func TestCreateAndGetPaste(t *testing.T) { func TestCreateAndGetPaste(t *testing.T) {
@@ -30,7 +42,9 @@ func TestCreateAndGetPaste(t *testing.T) {
if rec.Code != 201 { if rec.Code != 201 {
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String()) 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) json.Unmarshal(rec.Body.Bytes(), &created)
if len(created.ID) != 6 { if len(created.ID) != 6 {
t.Fatalf("unexpected id: %q", created.ID) t.Fatalf("unexpected id: %q", created.ID)
@@ -61,7 +75,9 @@ func TestPasswordProtection(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
// without password -> 401 // without password -> 401
@@ -121,10 +137,14 @@ func TestSoftDelete(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) 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) json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil) req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil)
req.Header.Set("Authorization", "Bearer "+created.DeletionToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != 200 { if rec.Code != 200 {
@@ -163,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) { func TestSweepSoftDeletesAfterGrace(t *testing.T) {
s := testServer(t) s := testServer(t)
h := s.routes() h := s.routes()
@@ -170,19 +230,21 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
s.store.SoftDelete(created.ID) s.store.SoftDelete(created.ID)
// simulate grace elapsed // simulate grace elapsed
past := time.Now().Unix() - (softDeleteGraceDays+1)*86400 past := time.Now().Unix() - (store.SoftDeleteGraceDays+1)*86400
s.store.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID) s.store.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
s.store.SweepExpired() s.store.SweepExpired()
var count int var count int
s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&count) count = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
if count != 0 { if count != 0 {
t.Fatal("expected hard delete after grace period") t.Fatal("expected hard delete after grace period")
} }
@@ -190,9 +252,9 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
func TestSlugCharset(t *testing.T) { func TestSlugCharset(t *testing.T) {
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
s := genSlug(6) s := store.GenSlug(6)
for _, c := range s { for _, c := range s {
if !strings.ContainsRune(slugAlphabet, c) { if !strings.ContainsRune(store.SlugAlphabet, c) {
t.Fatalf("bad char %q in slug %q", c, s) t.Fatalf("bad char %q in slug %q", c, s)
} }
} }
@@ -206,7 +268,9 @@ func TestRawEndpoint(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("GET", "/raw/"+created.ID, nil) req = httptest.NewRequest("GET", "/raw/"+created.ID, nil)
+115
View File
@@ -0,0 +1,115 @@
package api
import (
"palette/internal/store"
"palette/internal/web"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// doReq performs a request against the router, carrying the given cookies,
// and returns the recorder (so Set-Cookie from the viewer middleware is visible).
func doReq(t *testing.T, h http.Handler, method, path, cookie string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if cookie != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: cookie})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// viewerCookieFor performs a request without the vwr cookie and extracts the
// one the viewer middleware sets in the response.
func viewerCookieFor(t *testing.T, h http.Handler, path string) string {
t.Helper()
rec := doReq(t, h, "GET", path, "", "")
for _, c := range rec.Result().Cookies() {
if c.Name == "vwr" {
return c.Value
}
}
t.Fatal("vwr cookie not set")
return ""
}
func TestMineCreateListDelete(t *testing.T) {
globalLimiter = newLimiter() // fresh rate-limit buckets
st, err := store.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
ui, err := web.New()
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
h := a.routes()
alice := viewerCookieFor(t, h, "/history")
if alice == "" {
t.Fatal("no viewer cookie issued")
}
// create with alice's cookie -> stored viewer id
rec := doReq(t, h, "POST", "/api/pastes", alice, `{"content":"hello mine"}`)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID string }
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("no id returned")
}
// owner sees it in /api/mine
rec = doReq(t, h, "GET", "/api/mine", alice, "")
if rec.Code != 200 {
t.Fatalf("mine: %d", rec.Code)
}
var list struct {
Total int `json:"total"`
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 1 || len(list.Items) != 1 || list.Items[0].ID != created.ID {
t.Fatalf("mine list: total=%d items=%v", list.Total, list.Items)
}
// a different browser's cookie does NOT see it
bob := viewerCookieFor(t, h, "/history")
rec = doReq(t, h, "GET", "/api/mine", bob, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
t.Fatalf("other browser sees %d pastes, want 0", list.Total)
}
// delete enforcement: bob cannot delete alice's paste
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, bob, "")
if rec.Code != 403 {
t.Fatalf("bob delete: %d, want 403", rec.Code)
}
// owner can delete
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, alice, "")
if rec.Code != 200 {
t.Fatalf("alice delete: %d", rec.Code)
}
rec = doReq(t, h, "GET", "/api/mine", alice, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
t.Fatalf("after delete, mine total=%d, want 0", list.Total)
}
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// #34: the unlock cookie must be bound to the paste it unlocks, not a
// forgeable static value. A forged 'pw_<id>=1' cookie must not bypass the
// password check on the paste page.
func TestForgedUnlockCookieDoesNotBypassPassword(t *testing.T) {
globalLimiter = newLimiter()
s := testServer(t)
h := s.routes()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"SECRETPASTECONTENT","password":"hunter2"}`))
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: got %d", rec.Code)
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
id := created.ID
// request the page with a forged unlock cookie in the old format
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + id, Value: "1"})
h.ServeHTTP(rec, req)
if rec.Code == 200 && strings.Contains(rec.Body.String(), "SECRETPASTECONTENT") {
t.Fatal("forged pw_<id>=1 cookie bypassed password protection")
}
if rec.Code != 200 {
t.Logf("forged-cookie request returned %d (page still locked) — good", rec.Code)
}
}
+16
View File
@@ -0,0 +1,16 @@
package api
import (
"encoding/json"
"testing"
)
// test helpers for pentest tests (#34)
func jsonField(tb testing.TB, body, field string) string {
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
tb.Fatalf("bad json: %v", err)
}
v, _ := m[field].(string)
return v
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"net/http/httptest"
"strings"
"testing"
)
// #34: attacker-controlled content_type must not let a paste be served as
// HTML/SVG/XML from /raw (stored XSS). Only a fixed safe set passes through.
func TestRawRejectsHTMLContentType(t *testing.T) {
globalLimiter = newLimiter() // fresh rate-limit buckets
s := testServer(t)
h := s.routes()
for _, ct := range []string{
"text/html", "TEXT/HTML", "text/html;charset=utf-8", "text/html;x=1",
"application/xhtml+xml", "image/svg+xml", "text/html,",
} {
globalLimiter = newLimiter() // burst 5, loop makes 7 creates
body := `{"content":"<script>alert(1)</script>","content_type":"` + ct + `"}`
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("ct %q: create got %d: %s", ct, rec.Code, rec.Body.String())
}
id := jsonField(t, rec.Body.String(), "id")
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/raw/"+id, nil)
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Type"); got == ct {
t.Errorf("ct %q was served verbatim from /raw (stored XSS vector)", ct)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("ct %q: /raw missing X-Content-Type-Options: nosniff", ct)
}
}
}
func TestRawAllowsSafeContentType(t *testing.T) {
globalLimiter = newLimiter()
s := testServer(t)
h := s.routes()
for _, ct := range []string{"text/plain", "image/png", "application/pdf", "application/octet-stream"} {
globalLimiter = newLimiter()
body := `{"content":"hi","content_type":"` + ct + `"}`
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("ct %q: create got %d", ct, rec.Code)
}
id := jsonField(t, rec.Body.String(), "id")
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/raw/"+id, nil)
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Type"); got != ct {
t.Errorf("ct %q: got %q", ct, got)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Per-IP token bucket rate limiting (#2). Goroutine-safe via mutex.
type bucket struct {
tokens float64
last time.Time
rate float64 // tokens per second
burst float64
}
type limiter struct {
mu sync.Mutex
buckets map[string]*bucket
}
func newLimiter() *limiter {
return &limiter{buckets: make(map[string]*bucket)}
}
func (l *limiter) allow(key string, rate, burst float64) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: burst, last: now, rate: rate, burst: burst}
l.buckets[key] = b
}
elapsed := now.Sub(b.last).Seconds()
b.tokens += elapsed * b.rate
if b.tokens > b.burst {
b.tokens = b.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// clientIP extracts the request IP (no reverse proxy header by default).
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter()
// globalSettingsFn is set at startup; tests can point it at fixed settings.
var globalSettingsFn func() Settings
func globalSettings() Settings {
if globalSettingsFn != nil {
return globalSettingsFn()
}
return defaultSettings(Config{})
}
// rateLimitCreate uses the admin-tunable burst and per-minute refill (#40).
func rateLimitCreate(r *http.Request, s Settings) bool {
return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst)
}
// rateLimitGuess: 1 req/sec refill, burst 5, per IP.
func rateLimitGuess(r *http.Request) bool {
return globalLimiter.allow("guess:"+clientIP(r), 1, 5)
}
// rateLimitUnlock: 5 per minute per IP+paste.
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")
}
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
func setRateLimitHeaders(w http.ResponseWriter, limit, burst int) {
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
w.Header().Set("X-RateLimit-Burst", strconv.Itoa(burst))
}
+235
View File
@@ -0,0 +1,235 @@
package api
import (
"palette/internal/lang"
"palette/internal/store"
"palette/internal/web"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newTestServer(t *testing.T) *apiServer {
t.Helper()
globalLimiter = newLimiter() // fresh buckets per test
st, err := store.OpenStore(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
ui, err := web.New()
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 1024 * 1024}
ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
}
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
// TestRateLimitCreateBurst: burst of 5 creates allowed, then 429.
func TestRateLimitCreateBurst(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
// unique IP per test run so tests don't share buckets
reqIP := "10.9.9.1:1234"
for i := 0; i < 5; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("req %d: want 201, got %d: %s", i, rr.Code, rr.Body.String())
}
}
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("6th req: want 429, got %d", rr.Code)
}
if ra := rr.Header().Get("Retry-After"); ra == "" {
t.Fatal("missing Retry-After header")
}
if ra := rr.Header().Get("X-RateLimit-Limit"); ra == "" {
t.Fatal("missing X-RateLimit-Limit header")
}
}
// TestRateLimitRefill: after waiting >1s a token refills and a create succeeds.
func TestRateLimitRefill(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.9.9.2:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
}
time.Sleep(1100 * time.Millisecond)
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("after refill: want 201, got %d", rr.Code)
}
}
// TestRateLimitGuess: guess-language endpoint is limited too.
func TestRateLimitGuess(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.9.9.3:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"def f(): pass"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if i < 5 && rr.Code != 200 {
t.Fatalf("req %d: want 200, got %d", i, rr.Code)
}
}
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"x"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("want 429, got %d", rr.Code)
}
}
// TestRateLimitUnlock: 5 unlock attempts per IP+paste per minute, then 429.
func TestRateLimitUnlock(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
// create a password-protected paste
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret", "password": "pw1", "visibility": "unlisted"})
if rr.Code != 201 {
t.Fatalf("create failed: %d", rr.Code)
}
var created map[string]any
json.Unmarshal(rr.Body.Bytes(), &created)
id := created["id"].(string)
reqIP := "10.9.9.4:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = reqIP
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if i < 5 && rr2.Code == 429 {
t.Fatalf("req %d: unexpected 429", i)
}
}
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = reqIP
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if rr2.Code != 429 {
t.Fatalf("want 429, got %d", rr2.Code)
}
}
// TestHighlightCode basic expectations.
func TestHighlightCode(t *testing.T) {
in := "func main() {\n\t// comment\n\tfmt.Println(\"hello\")\n}\n"
out := lang.HighlightCode(in, "go")
if !bytes.Contains([]byte(out), []byte(`<span class="tok-kw">func</span>`)) {
t.Fatalf("no keyword span: %s", out)
}
if !bytes.Contains([]byte(out), []byte(`<span class="tok-com">// comment</span>`)) {
t.Fatalf("no comment span: %s", out)
}
if !bytes.Contains([]byte(out), []byte(`tok-str">&#34;hello&#34;</span>`)) {
t.Fatalf("no string span: %s", out)
}
// unsupported language returns escaped plain text
plain := lang.HighlightCode("<b>x</b>", "text")
if plain != "&lt;b&gt;x&lt;/b&gt;" {
t.Fatalf("plain escaping wrong: %q", plain)
}
// line count preserved (gutter alignment)
if got := len(splitLines(lang.HighlightCode("a\nb\nc", "go"))); got != 3 {
t.Fatalf("want 3 lines, got %d", got)
}
}
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
// TestCreatorAutoUnlock: create with password, then POST the password to
// /{id}, then GET /{id} with the cookie shows the paste (#26).
func TestCreatorAutoUnlock(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret stuff", "password": "pw2", "visibility": "unlisted"})
if rr.Code != 201 {
t.Fatalf("create failed: %d", rr.Code)
}
var created map[string]any
json.Unmarshal(rr.Body.Bytes(), &created)
id := created["id"].(string)
// locked GET shows unlock page
req := httptest.NewRequest("GET", "/"+id, nil)
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if bytes.Contains(rr2.Body.Bytes(), []byte("secret stuff")) {
t.Fatal("locked paste leaked content")
}
// unlock POST with ?next= should set cookie and redirect
req = httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=pw2&next=/"+id+"?created=1")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr3 := httptest.NewRecorder()
h.ServeHTTP(rr3, req)
if rr3.Code != http.StatusSeeOther {
t.Fatalf("unlock POST: want 303, got %d", rr3.Code)
}
var cookie *http.Cookie
for _, c := range rr3.Result().Cookies() {
if c.Name == "pw_"+id {
cookie = c
}
}
if cookie == nil {
t.Fatal("no pw_ cookie set")
}
// GET with cookie shows content
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(cookie)
rr4 := httptest.NewRecorder()
h.ServeHTTP(rr4, req)
if !bytes.Contains(rr4.Body.Bytes(), []byte("secret stuff")) {
t.Fatalf("cookie unlock failed: %d %s", rr4.Code, rr4.Body.String())
}
}
+464
View File
@@ -0,0 +1,464 @@
// Package api implements palette's REST handlers and the HTTP router:
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"database/sql"
"palette/internal/store"
"palette/internal/web"
)
type Config struct {
Addr string
DBPath string
MaxTextBytes int64
MaxItemBytes int64
}
type apiServer struct {
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 {
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// Routes returns the HTTP handler for the server.
func (a *apiServer) Routes() http.Handler {
return a.routes()
}
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)
r.Get("/admin/api/settings", a.adminAuth(a.handleAdminGetSettings, a.adminKey))
r.Post("/admin/api/settings", a.adminAuth(a.handleAdminPostSettings, a.adminKey))
// API
r.Route("/api", func(r chi.Router) {
r.Post("/pastes", a.handleCreatePaste)
r.Get("/pastes/{id}", a.handleGetPaste)
r.Delete("/pastes/{id}", a.handleDeletePaste)
r.Get("/mine", a.handleListMine)
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic)
r.Post("/guess-language", a.handleGuessLang)
r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
})
// can page
r.Get("/can/{id}", a.handleCanPage)
// raw
r.Get("/raw/{id}", a.handleRaw)
// web pages
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
r.Get("/new", a.ui.Handlers().HandleNewPage)
r.Get("/history", a.ui.Handlers().HandleHistoryPage)
r.Get("/settings", a.ui.Handlers().HandleSettingsPage)
r.Get("/mine", a.ui.Handlers().HandleMinePage)
r.Handle("/static/*", a.ui.StaticHandler())
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
r.Get("/{id}", a.handlePasteView)
r.Post("/{id}", a.handlePasteView)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not found")
})
return r
}
// viewerCookieMiddleware ensures every request carries an anonymous browser id
// cookie ("vwr"); sets one on the response if absent. Used by /mine (#37, #49).
func viewerCookieMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("vwr"); err != nil || c.Value == "" {
id := store.GenSlug(16)
http.SetCookie(w, &http.Cookie{
Name: "vwr", Value: id, Path: "/",
MaxAge: 31536000, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
r.AddCookie(&http.Cookie{Name: "vwr", Value: id})
// remember that this cookie was minted here, not sent by the client
r = r.WithContext(context.WithValue(r.Context(), vwrMintedKey, true))
}
next.ServeHTTP(w, r)
})
}
type vwrMintedKeyType struct{}
var vwrMintedKey vwrMintedKeyType
func currentViewerID(r *http.Request) string {
if c, err := r.Cookie("vwr"); err == nil {
return c.Value
}
return ""
}
// viewerSentCookie reports whether the client itself sent a vwr cookie
// (as opposed to the middleware minting one for this request).
func viewerSentCookie(r *http.Request) bool {
if _, err := r.Cookie("vwr"); err != nil {
return false
}
_, minted := r.Context().Value(vwrMintedKey).(bool)
return !minted
}
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
s := a.settings.get()
setRateLimitHeaders(w, 1, 5)
if !rateLimitCreate(r, s) {
writeRateLimited(w, 1)
return
}
var p store.Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
writeBodyTooLarge(w)
return
}
writeErr(w, 400, "invalid json body")
return
}
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
writeErr(w, status, msg)
return
}
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 != "" {
def := s.DefaultExpiry
p.ExpiresIn = &def
}
p.ViewerID = currentViewerID(r)
created, err := a.store.CreatePaste(&p)
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 201, map[string]any{
"id": created.ID,
"deletion_token": created.DeletionToken,
"url": "/" + created.ID,
"raw_url": "/raw/" + created.ID,
"api_url": "/api/pastes/" + created.ID,
"expires_at": created.ExpiresAt,
"created_at": created.CreatedAt,
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
})
}
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil {
writeErr(w, 500, "db error")
return
}
if row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
writeErr(w, 404, "paste expired")
return
}
if row.Burned() { // #49: read budget exhausted
writeErr(w, 404, "paste not found")
return
}
if row.PasswordHash.Valid {
// require password via header or query
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
pw = r.URL.Query().Get("password")
}
if pw == "" || !store.CheckPassword(row.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
// #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,
"view_count": row.ViewCount, "visibility": row.Visibility,
"reads_remaining": rem,
})
}
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
// #63: deletion requires authorization. Either the deletion token issued
// at create time (Authorization header or ?token= query param, matching
// the create response's "deletion_token" field), or the creator browser
// itself (client-sent vwr cookie matching the paste's viewer, #37).
if !a.deletionAuthorized(r, row) {
writeErr(w, 403, "deletion token required")
return
}
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)
if vid == "" {
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
return
}
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")
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
})
}
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 := parseLimit(r, 25, 100)
offset := parseOffset(r)
rows, total, err := a.store.ListPublic(limit, offset)
if err != nil {
writeErr(w, 500, "db error")
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug),
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
}
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
http.Error(w, "not found", 404)
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
http.Error(w, "paste expired", 404)
return
}
if row.PasswordHash.Valid {
http.Error(w, "password required", 401)
return
}
if row.Burned() { // #49: read budget exhausted
http.Error(w, "not found", 404)
return
}
// #49 decision: raw reads count against the read budget too, with the
// 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 —
// stored XSS. Only pass through a fixed safe set; anything else is
// served as plain text with nosniff.
ct := row.ContentType
if !safeRawContentType(ct) {
ct = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
a.store.IncrementViews(row.ID)
w.Write([]byte(row.Content))
}
// safeRawContentType reports whether ct is in the fixed set of types that are
// safe to serve verbatim on /raw (no active-content execution contexts).
func safeRawContentType(ct string) bool {
base := ct
if i := strings.IndexByte(ct, ';'); i >= 0 {
base = ct[:i]
}
base = strings.ToLower(strings.TrimSpace(base))
switch base {
case "text/plain", "text/markdown", "text/x-markdown",
"application/json", "application/pdf",
"image/png", "image/jpeg", "image/gif", "image/webp",
"application/octet-stream":
return true
}
return false
}
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
http.NotFound(w, r)
return
}
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)
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)
}
fmt.Fprintf(w, "</ul></body></html>")
}
func templateEsc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
h := a.webHandlers()
// unlock POST rate limiting is wired through h.RateLimitOK
h.HandlePasteView(w, r)
}
func (a *apiServer) webHandlers() *web.Handlers {
return &web.Handlers{
UI: a.ui,
Store: a.store,
ViewerID: currentViewerID,
BurnWindowMin: a.burnViewerWindow,
RateLimitOK: func(id string, r *http.Request) bool { return rateLimitUnlock(id, r) },
}
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func envIntOr(k string, d int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return d
}
func nullStrOr(ns sql.NullString, def string) string {
if ns.Valid {
return ns.String
}
return def
}
// EnvOr returns the env var value or default.
func EnvOr(k, d string) string { return envOr(k, d) }
// EnvIntOr returns the env int value or default.
func EnvIntOr(k string, d int) int { return envIntOr(k, d) }
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"palette/internal/store"
"testing"
"time"
)
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
// created_at/expires_at, bypassing the API's timestamp handling.
func insertPasteWithSlug(t *testing.T, s *store.Store, slug string, createdAt, expiresAt int64) string {
t.Helper()
id := store.GenSlug(6)
if _, err := s.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt); err != nil {
t.Fatal(err)
}
return id
}
func strPtr(s string) *string { return &s }
func TestReleaseSlugOnExpiredPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("release-notes"); taken {
t.Fatal("slug should be released after expiry")
}
// slug must be reusable by a new paste
p, err := s.store.CreatePaste(&store.Paste{Content: "new", CustomSlug: strPtr("release-notes")})
if err != nil {
t.Fatalf("reuse slug: %v", err)
}
if p.CustomSlug == nil || *p.CustomSlug != "release-notes" {
t.Fatal("new paste did not claim released slug")
}
}
func TestReleaseSlugOnOldPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
// created 31 days ago, no expiry -> released by 30-day reservation rule
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("old-url"); taken {
t.Fatal("slug should be released after 30-day reservation")
}
}
func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 0 {
t.Fatalf("released %d err %v, want 0", n, err)
}
for _, slug := range []string{"fresh-url", "fresh-url2"} {
if taken, _ := s.store.SlugTaken(slug); !taken {
t.Fatalf("slug %q should still be held", slug)
}
}
}
func TestSweeperTickerReleasesSlugs(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
s.store.StartSweeper(10*time.Millisecond, store.SlugReservationDays)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("ticker did not release slug in time")
}
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"net/http/httptest"
"strings"
"testing"
)
// #33 sweep: the create API must enforce the same expiry window as the UI
// (1 minute .. 1 year). Previously -1h, 0s, 1ns and 30000h were all accepted,
// producing pastes that were born expired or effectively permanent.
func TestCreatePasteExpiryBounds(t *testing.T) {
s := testServer(t)
h := s.routes()
cases := []struct {
expiresIn string
wantCode int
}{
{"-1h", 400},
{"-0s", 400},
{"0s", 400},
{"1ns", 400},
{"59s", 400},
{"1m", 201},
{"90s", 201},
{"8760h", 201}, // exactly 1 year
{"8785h", 400}, // 1 year + 1 day: over the max
{"30000h", 400}, // ~3.4 years, over the max
}
globalLimiter = newLimiter() // one fresh bucket for the whole table
for _, c := range cases {
globalLimiter = newLimiter() // avoid create rate limit between cases
req := httptest.NewRequest("POST", "/api/pastes",
strings.NewReader(`{"content":"x","expires_in":"`+c.expiresIn+`"}`))
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())
}
}
}
// #33 sweep: HTML paste views must increment view_count. The increment was
// missing from handlePasteView, so the counter only moved on /raw.
func TestPasteViewIncrementsViewCount(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vc"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
// first render counts (no ?created=1 here: that's the just-created banner case)
req = httptest.NewRequest("GET", "/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("view: got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("api get: got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `"view_count":2`) {
t.Fatalf("expected view_count 2 after two HTML views, got: %s", body)
}
}
// #33 sweep: the just-created banner render (?created=1) must NOT count as a
// view for the creator.
func TestJustCreatedViewDoesNotCount(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"jc"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
req = httptest.NewRequest("GET", "/"+id+"?created=1&token=t", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), `"view_count":1`) {
t.Fatalf("just-created render counted as a view: %s", rec.Body.String())
}
}
+92
View File
@@ -0,0 +1,92 @@
package api
import (
"errors"
"fmt"
"net/http"
"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
}
+110
View File
@@ -0,0 +1,110 @@
package lang
import (
"encoding/json"
"regexp"
"strings"
"github.com/go-enry/go-enry/v2"
)
// hintRule is a lightweight regex hint that nudges detection. Hints don't
// decide on their own: matching hints are passed to enry's classifier as
// candidate languages, and enry (trained on Linguist samples) makes the final
// call. Adding a language later is usually a one-line addition here plus the
// dropdown in web/templates/new.html.
type hintRule struct {
lang string // enry language name
re *regexp.Regexp
}
// hintRules are evaluated in order; keep more specific languages earlier so
// ties break in their favor.
var hintRules = []hintRule{
{"Dockerfile", regexp.MustCompile(`(?mi)^(FROM\s+\S+:\S*|RUN\s+\S+|COPY\s+\S+\s+\S+|ENTRYPOINT\s+|WORKDIR\s+/)`)},
{"Diff", regexp.MustCompile(`(?m)^(diff --git|--- a/|\+\+\+ b/|@@ -\d+)`)},
{"PHP", regexp.MustCompile(`(?m)<\?php|\$\w+\s*=\s*[^=]|\becho\s+["'$]`)},
{"HTML", regexp.MustCompile(`(?i)<(!DOCTYPE|html|head|body|div|span|script|p|a)\b`)},
{"XML", regexp.MustCompile(`(?m)^<\?xml\b|<\/?[a-zA-Z][\w.-]*:[\w.-]*[>\s]`)},
{"CSS", regexp.MustCompile(`(?m)(^|\})\s*[^{}@]+\{[^}]*:[^}]*\}|@(media|import|font-face)\b`)},
{"TypeScript", regexp.MustCompile(`(?m)(:\s*(string|number|boolean|any)\b|\binterface \w+ \{|\btype \w+ =|\bimplements \w+)`)},
{"TOML", regexp.MustCompile(`(?m)^\[[\w."-]+\]\s*$|^\w[\w-]*\s*=\s*("[^"]*"|\d+|true|false|\[[^\]]*\])\s*$`)},
{"INI", regexp.MustCompile(`(?m)^\[[\w.-]+\]\s*$|^\w[\w.-]*\s*=\s*\S+\s*$`)},
{"Ruby", regexp.MustCompile(`(?m)(\bdef \w+($|\s)|\brequire ['"]|\bputs \w+|@\w+\s*=\s*[^=]|\bend\b\s*$)`)},
{"Perl", regexp.MustCompile(`(?m)(\buse strict\b|\bmy \$\w+|->\{|sub \w+ \{)`)},
{"Lua", regexp.MustCompile(`(?m)(\bfunction\s+\w+\s*\(|\blocal \w+\s*=|\bthen\b|\belseif\b|\.\.\.)`)},
{"Go", regexp.MustCompile(`(?m)^\s*(package \w+|import \(|func (\w+|\() )`)},
{"Python", regexp.MustCompile(`(?m)^\s*(def \w+|class \w+|import \w+|from \w+ import |@\w+)`)},
{"JavaScript", regexp.MustCompile(`(?m)(\bconst \w+ = |require\(|import \w+ from |=> \{|\bconsole\.log\()` )},
{"Rust", regexp.MustCompile(`(?m)(\bfn \w+|let mut \b|\bimpl \b|use std::)`)},
{"Java", regexp.MustCompile(`(?m)(\bpublic (static |final |class )|\bSystem\.out\.print|import java\.)`)},
{"C", regexp.MustCompile(`(?m)(#include\s*<\w+\.h>|printf\(|\bint main\()` )},
{"C++", regexp.MustCompile(`(?m)(#include\s*<(iostream|vector|string)>|std::|\bcout\s*<<)`)},
{"SQL", regexp.MustCompile(`(?i)\b(SELECT .+ FROM|INSERT INTO|CREATE TABLE|UPDATE \w+ SET)\b`)},
{"YAML", regexp.MustCompile(`(?m)^(\w[\w-]*:\s*(\||\S)| \w[\w-]*: |---\s*$)`)},
{"Markdown", regexp.MustCompile("(?m)^(#{1,6} \\S|\\|.*\\||-\\s\\[\\s?\\]|```)")},
{"Shell", regexp.MustCompile(`(?m)^(#!.*bash|#!.*sh|\w+\(\)\s*\{)` )},
}
// canonical maps enry display names to the lowercase ids we store and render.
var canonical = map[string]string{
"Dockerfile": "dockerfile",
"C#": "csharp",
"Shell": "bash",
}
// guessLang detects a language from pasted content. Order: fast decisive
// paths (empty, JSON, unambiguous markers enry can't see without a filename),
// then enry strategies (shebangs, XML decl, modelines, content heuristics),
// then enry's classifier seeded by our regex hints.
// GuessLang detects a language from pasted content.
func GuessLang(s string) string {
src := strings.TrimSpace(s)
if src == "" {
return ""
}
// JSON: must start with { or [ and parse — cheaper and more decisive
// than the classifier for pasted JSON, and handles compact single-line
// JSON that content heuristics miss.
if src[0] == '{' || src[0] == '[' {
var v any
if json.Unmarshal([]byte(src), &v) == nil {
return "json"
}
}
// enry's built-in strategies: shebangs, XML declaration, modelines,
// content heuristics.
if lang := enry.GetLanguage("", []byte(src)); lang != "" && lang != enry.OtherLanguage {
return normalizeLang(lang)
}
// collect hint-matched languages as classifier candidates
cands := []string{}
for _, h := range hintRules {
if h.re.MatchString(src) {
cands = append(cands, h.lang)
}
}
if len(cands) > 0 {
// enry's Bayesian classifier (trained on Linguist samples) picks the
// best of the hint candidates; fall back to the first hint if it
// can't decide.
if lang, _ := enry.GetLanguageByClassifier([]byte(src), cands); lang != "" {
return normalizeLang(lang)
}
return normalizeLang(cands[0])
}
return "text"
}
// normalizeLang maps enry display names to our lowercase stored ids.
func normalizeLang(lang string) string {
if c, ok := canonical[lang]; ok {
return c
}
return strings.ToLower(lang)
}
+92
View File
@@ -0,0 +1,92 @@
package lang
import (
"strings"
"testing"
)
// TestGuessLangExisting covers languages detected before the enry switch and
// still expected to work after it.
func TestGuessLangExisting(t *testing.T) {
cases := map[string]string{
"package main\n\nfunc main() {}\n": "go",
"def foo():\n return 1\n": "python",
"const x = 1;\nconsole.log(x);\n": "javascript",
"{\"a\": 1, \"b\": [2, 3]}\n": "json",
"hello world just some text": "text",
"": "",
"fn main() {\n let x = 1;\n}\n": "rust",
"#include <stdio.h>\nint main() { printf(\"hi\"); }\n": "c",
"SELECT id, name FROM users WHERE active = 1;\n": "sql",
"title: demo\nitems:\n - one\n - two\n": "yaml",
"# Demo\n\nsome *markdown* text with a [link](http://x)\n": "markdown",
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
}
for src, want := range cases {
if got := GuessLang(src); got != want {
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
}
}
}
// TestGuessLangNewLanguages covers the languages added to the dropdown as part
// of the enry integration (#41).
func TestGuessLangNewLanguages(t *testing.T) {
cases := map[string]string{
"interface User {\n name: string;\n age: number;\n}\n": "typescript",
"<!DOCTYPE html>\n<html>\n<head><title>hi</title></head>\n</html>\n": "html",
".container {\n display: flex;\n padding: 4px;\n}\n": "css",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root><item>x</item></root>\n": "xml",
"<?php\nfunction hi() { echo 'x'; }\n": "php",
"def greet(name)\n puts \"hi #{name}\"\nend\n": "ruby",
"use strict;\nmy $x = 5;\nprint \"x is $x\\n\";\n": "perl",
"local x = 10\nfunction add(a, b)\n return a + b\nend\n": "lua",
"FROM golang:1.22\nRUN go build -o app .\nCMD [\"./app\"]\n": "dockerfile",
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n": "toml",
"[server]\nhost = 127.0.0.1\nport = 8080\n": "ini",
"diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1 +1 @@\n": "diff",
}
for src, want := range cases {
if got := GuessLang(src); got != want {
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
}
}
}
// TestGuessLangMagicMarkers verifies enry's built-in shebang / signature
// handling that replaced the hand-rolled magic-marker pre-checks (#41).
func TestGuessLangMagicMarkers(t *testing.T) {
cases := map[string]string{
"#!/usr/bin/env node\nconsole.log('hi');\n": "javascript",
"#!/usr/bin/env python3\nimport sys\nprint(sys.argv)\n": "python",
"#!/usr/bin/python\nprint('x')\n": "python",
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
"#!/bin/sh\necho hi\n": "bash",
"<?php\necho 'x';\n": "php",
"<!DOCTYPE html>\n<html><body></body></html>": "html",
"FROM alpine:3.19\nCOPY app /app\n": "dockerfile",
"FROM ubuntu:24.04\nRUN apt-get update\n": "dockerfile",
"diff --git a/x.txt b/x.txt\nindex 123..456 100644\n": "diff",
"--- a/config.yml\n+++ b/config.yml\n@@ -1,2 +1,3 @@\n": "diff",
}
for src, want := range cases {
if got := GuessLang(src); got != want {
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
}
}
}
// TestGuessLangCanonical verifies enry display names are mapped/lowercased to
// our stored ids.
func TestGuessLangCanonical(t *testing.T) {
if got := GuessLang("FROM debian:12\nCMD [\"sh\"]\n"); got != "dockerfile" {
t.Errorf("Dockerfile canonical mapping failed: got %q", got)
}
if got := GuessLang("#!/bin/sh\necho hi\n"); got != "bash" {
t.Errorf("Shell canonical mapping failed: got %q", got)
}
// uncurated languages still come back lowercase
if got := GuessLang("<h1>{{.Name}}</h1>\n"); got != strings.ToLower(got) {
t.Errorf("expected lowercase output, got %q", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
package lang
import (
"html"
"regexp"
"strings"
)
// Minimal regex-based syntax highlighter for the paste view (#1).
// Server-side, no external dependencies. Tokens: comments, strings,
// numbers, keywords. Output is HTML with span classes styled in app.css.
// Highlighting is applied per line so the gutter stays line-aligned.
type hlLang struct {
keywords map[string]bool
lineComps []string // line comment prefixes
blockCom [2]string
}
var hlLangs = map[string]hlLang{
"go": {
keywords: set("break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false string int int64 int32 uint byte rune bool float64 float32 error make new len cap append panic recover"),
lineComps: []string{"//"},
blockCom: [2]string{"/*", "*/"},
},
"python": {
keywords: set("and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield self print len range str int float list dict set tuple open"),
lineComps: []string{"#"},
},
"javascript": {
keywords: set("async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new null of return static super switch this throw true false try typeof undefined var void while with yield console log document window Math JSON Array Object String Number Boolean Promise"),
lineComps: []string{"//"},
blockCom: [2]string{"/*", "*/"},
},
"json": {
keywords: set("true false null"),
},
"bash": {
keywords: set("if then else elif fi for while do done case esac function return exit local export echo cd ls grep awk sed cat curl sudo apt git make echo read shift set unset trap source alias printf test rm mv cp mkdir chmod chown"),
lineComps: []string{"#"},
},
"sql": {
keywords: set("SELECT FROM WHERE INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE DROP ALTER INDEX JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER HAVING LIMIT OFFSET AND OR NOT NULL IS IN AS DISTINCT UNION ALL PRIMARY KEY FOREIGN REFERENCES DEFAULT UNIQUE CHECK VIEW WITH RETURNING EXISTS CASE WHEN THEN ELSE END COUNT SUM AVG MIN MAX"),
lineComps: []string{"--"},
blockCom: [2]string{"/*", "*/"},
},
}
// aliases from the language dropdown / guesser
var hlAliases = map[string]string{
"py": "python", "python3": "python",
"js": "javascript", "node": "javascript", "typescript": "javascript", "ts": "javascript",
"sh": "bash", "shell": "bash", "zsh": "bash",
"golang": "go",
"c": "go", "cpp": "go", "c++": "go", "java": "go", "rust": "go", "rs": "go",
// C-family shares the same token rules as Go for highlighting purposes
}
func set(words string) map[string]bool {
m := make(map[string]bool)
for _, w := range strings.Fields(words) {
m[w] = true
}
return m
}
func resolveLang(lang string) (string, hlLang, bool) {
l := strings.ToLower(strings.TrimSpace(lang))
if l == "" || l == "text" || l == "markdown" || l == "yaml" {
return "", hlLang{}, false
}
if l == "yml" {
return "", hlLang{}, false
}
if g, ok := hlAliases[l]; ok {
if h, ok2 := hlLangs[g]; ok2 {
return g, h, true
}
return "", hlLang{}, false
}
h, ok := hlLangs[l]
return l, h, ok
}
var hlTokenRe = regexp.MustCompile(`("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|` + "`" + `[^` + "`" + `]*` + "`" + `?|//[^\n]*|--[^\n]*|#[^\n]*|/\*.*?(?:\*/|$)|\b(?:[0-9]+\.?[0-9]*|0x[0-9a-fA-F]+)\b|[A-Za-z_][A-Za-z0-9_]*)`)
func highlightLine(line string, h hlLang, lang string) string {
var b strings.Builder
rest := line
// strip a trailing block-comment opener handled below; regex covers it
for {
loc := hlTokenRe.FindStringIndex(rest)
if loc == nil {
b.WriteString(html.EscapeString(rest))
break
}
b.WriteString(html.EscapeString(rest[:loc[0]]))
tok := rest[loc[0]:loc[1]]
cls := ""
switch {
case strings.HasPrefix(tok, "//") || strings.HasPrefix(tok, "#") ||
strings.HasPrefix(tok, "--") || strings.HasPrefix(tok, "/*"):
// '#/--' are comments only in langs that use them
if (strings.HasPrefix(tok, "#") && !containsStr(h.lineComps, "#")) ||
(strings.HasPrefix(tok, "--") && !containsStr(h.lineComps, "--")) {
cls = ""
} else {
cls = "tok-com"
}
case strings.HasPrefix(tok, "\"") || strings.HasPrefix(tok, "'") || strings.HasPrefix(tok, "`"):
cls = "tok-str"
case tok[0] >= '0' && tok[0] <= '9':
cls = "tok-num"
case h.keywords[tok]:
cls = "tok-kw"
}
if cls != "" {
b.WriteString(`<span class="` + cls + `">` + html.EscapeString(tok) + `</span>`)
} else {
b.WriteString(html.EscapeString(tok))
}
rest = rest[loc[1]:]
}
return b.String()
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// highlightCode returns HTML with highlighting spans; safe because all
// non-token text is html-escaped.
func HighlightCode(content, langID string) string {
l, h, ok := resolveLang(langID)
_ = l
if !ok {
return html.EscapeString(content)
}
lines := strings.Split(content, "\n")
out := make([]string, len(lines))
for i, line := range lines {
out[i] = highlightLine(line, h, langID)
}
return strings.Join(out, "\n")
}
+96
View File
@@ -0,0 +1,96 @@
package store
import (
"crypto/subtle"
"database/sql"
"encoding/base64"
"time"
)
// genDeletionToken returns a 32-char url-safe random token
func genDeletionToken() string {
b := make([]byte, 24)
cryptoRead(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// TimeNow is overridable in tests to inject the clock.
var TimeNow = time.Now
// 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.
// #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 {
// #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, true
}
now := TimeNow().Unix()
var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
row.ID, viewerID).Scan(&last)
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
r := int(row.ReadsLimit.Int64) - row.ReadsUsed
if r < 0 {
r = 0
}
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)
// #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)
if r < 0 {
r = 0
}
return &r, true
}
// Burned reports whether a read-limited paste has exhausted its budget.
func (row *PasteRow) Burned() bool {
return row.ReadsLimit.Valid && int64(row.ReadsUsed) >= row.ReadsLimit.Int64
}
func DeletionTokenEqual(stored, given string) bool {
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
}
@@ -1,8 +1,7 @@
package main package store
import ( import (
"errors" "errors"
"fmt"
"regexp" "regexp"
"strings" "strings"
) )
@@ -16,16 +15,16 @@ var reservedSlugs = map[string]bool{
"new": true, "login": true, "logout": true, "admin": true, "settings": true, "new": true, "login": true, "logout": true, "admin": true, "settings": true,
} }
var errInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit") var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
var errReservedSlug = errors.New("that slug is reserved") var ErrReservedSlug = errors.New("that slug is reserved")
var errSlugTaken = errors.New("that slug is already taken") var ErrSlugTaken = errors.New("that slug is already taken")
func ValidateCustomSlug(slug string) error { func ValidateCustomSlug(slug string) error {
if !slugRE.MatchString(slug) { if !slugRE.MatchString(slug) {
return errInvalidSlug return ErrInvalidSlug
} }
if reservedSlugs[strings.ToLower(slug)] { if reservedSlugs[strings.ToLower(slug)] {
return errReservedSlug return ErrReservedSlug
} }
return nil return nil
} }
@@ -45,5 +44,3 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
} }
return n > 0, nil return n > 0, nil
} }
var _ = fmt.Sprintf // keep fmt if unused later
+5 -5
View File
@@ -1,4 +1,4 @@
package main package store
import ( import (
"crypto/rand" "crypto/rand"
@@ -19,7 +19,8 @@ const (
argonSaltLen = 16 argonSaltLen = 16
) )
func argon2idHash(pw string) (string, error) { // Argon2IDHash hashes a password with argon2id.
func Argon2IDHash(pw string) (string, error) {
salt := make([]byte, argonSaltLen) salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil { if _, err := rand.Read(salt); err != nil {
return "", err return "", err
@@ -27,11 +28,10 @@ func argon2idHash(pw string) (string, error) {
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen) key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
base64.RawStdEncoding.EncodeToString(key)), nil
} }
func checkPassword(hash, pw string) bool { func CheckPassword(hash, pw string) bool {
parts := strings.Split(hash, "$") parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" { if len(parts) != 6 || parts[1] != "argon2id" {
return false return false
+480
View File
@@ -0,0 +1,480 @@
// Package store provides the SQLite persistence layer for palette: schema
// migrations, the Store type and all queries, and the background sweeper.
package store
import (
"database/sql"
"errors"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
)
// SlugReservationDays is the default custom-URL reservation window (admin-tunable via settings, #40).
const SlugReservationDays = 30
// SoftDeleteGraceDays is how long soft-deleted pastes linger before hard delete.
const SoftDeleteGraceDays = 7
type Paste struct {
ID string `json:"id"`
CustomSlug *string `json:"custom_slug,omitempty"`
Content string `json:"content"`
ContentType string `json:"content_type"`
Language *string `json:"language,omitempty"`
Title *string `json:"title,omitempty"`
Password *string `json:"password,omitempty"`
ExpiresIn *string `json:"expires_in,omitempty"`
BurnAfterRead bool `json:"burn_after_read,omitempty"`
BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1)
Visibility string `json:"visibility"`
CanID *string `json:"can_id,omitempty"`
CreatedAt int64 `json:"created_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
ViewerID string `json:"-"` // set from vwr cookie server-side (#37)
readsLimit *int64 // #49: resolved read budget, not serialized
ViewCount int `json:"view_count"`
DeletionToken string `json:"-"`
}
type PasteRow struct {
ID string
CustomSlug sql.NullString
Content string
ContentType string
Language sql.NullString
Title sql.NullString
PasswordHash sql.NullString
ExpiresAt sql.NullInt64
BurnAfterRead bool
ReadsLimit sql.NullInt64
ReadsUsed int
Visibility string
CanID sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ViewCount int
Size int
DeletionToken sql.NullString
ViewerID sql.NullString
}
type CanRow struct {
ID string
Title sql.NullString
Visibility string
PasswordHash sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64
}
type Store struct {
db *sql.DB
}
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
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
}
return s, nil
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS pastes (
id TEXT PRIMARY KEY,
custom_slug TEXT UNIQUE,
content TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'text/plain',
language TEXT,
title TEXT,
password_hash TEXT,
expires_at INTEGER,
burn_after_read INTEGER DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'public',
can_id TEXT,
created_at INTEGER NOT NULL,
deleted_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0,
deletion_token TEXT
);
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS paste_cans (
id TEXT PRIMARY KEY,
title TEXT,
description TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
password_hash TEXT,
created_at INTEGER NOT NULL,
deleted_at INTEGER,
expires_at INTEGER
);
`)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL,
last_viewed INTEGER NOT NULL,
PRIMARY KEY (paste_id, viewer_id)
)`) // #49: per-viewer read dedupe window
return err
}
// SlugAlphabet is the paste-id charset (no ambiguous chars).
var SlugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
// genSlug generates a random slug of length n.
func genSlug(n int) string {
b := make([]byte, n)
_, _ = cryptoRead(b)
for i := range b {
b[i] = SlugAlphabet[int(b[i])%len(SlugAlphabet)]
}
return string(b)
}
// validExpiry reports whether an expires_in duration is in the accepted
// window. The UI restricts presets to 1 minute - 1 year (#48); the API must
// enforce the same bounds, otherwise negative/zero/absurd durations create
// pastes that are born expired (or effectively permanent).
const (
minExpiry = time.Minute
maxExpiry = 366 * 24 * time.Hour // 1 year (+ leap day headroom)
)
func ValidExpiry(d time.Duration) bool {
return d >= minExpiry && d <= maxExpiry
}
func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
id := genSlug(6)
now := time.Now().Unix()
var expiresAt *int64
if p.ExpiresIn != nil && *p.ExpiresIn != "" {
d, err := time.ParseDuration(*p.ExpiresIn)
if err != nil {
return nil, fmt.Errorf("invalid expires_in: %w", err)
}
if !ValidExpiry(d) {
return nil, fmt.Errorf("expires_in must be between 1 minute and 1 year")
}
t := now + int64(d.Seconds())
expiresAt = &t
}
var pwHash *string
if p.Password != nil && *p.Password != "" {
h, err := Argon2IDHash(*p.Password)
if err != nil {
return nil, err
}
pwHash = &h
}
if p.CustomSlug != nil && *p.CustomSlug != "" {
slug := *p.CustomSlug
if err := ValidateCustomSlug(slug); err != nil {
return nil, err
}
taken, err := s.SlugTaken(slug)
if err != nil {
return nil, err
}
if taken {
return nil, ErrSlugTaken
}
}
// #49: burn-after-read pastes carry a read budget (default 1 read)
if p.BurnAfterRead {
limit := int64(1)
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
limit = int64(*p.BurnAfterReads)
}
p.readsLimit = &limit
}
visibility := p.Visibility
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
return nil, errors.New("visibility must be public or unlisted")
}
contentType := p.ContentType
if contentType == "" {
contentType = "text/plain"
}
var slugVal *string
if p.CustomSlug != nil && *p.CustomSlug != "" {
slugVal = p.CustomSlug
}
p.DeletionToken = genDeletionToken()
_, err := s.db.Exec(`INSERT INTO pastes
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id, reads_limit)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID, p.readsLimit)
if err != nil {
return nil, err
}
p.ID = id
p.CreatedAt = now
p.ExpiresAt = expiresAt
p.Visibility = visibility
return p, nil
}
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
var r PasteRow
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
if err == sql.ErrNoRows {
return nil, nil
}
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 password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []PasteRow
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 {
return nil, 0, err
}
r.CustomSlug = cs
r.Language = lang
r.Title = title
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total)
return out, total, nil
}
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content)
FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []PasteRow
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 {
return nil, 0, err
}
r.CustomSlug, r.Language, r.Title = cs, lang, title
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)
return out, total, nil
}
// MineOwner returns the stored viewer_id for a paste, or "" if none.
func (s *Store) MineOwner(id string) (string, error) {
var vid sql.NullString
err := s.db.QueryRow(`SELECT viewer_id FROM pastes WHERE id = ? AND deleted_at IS NULL`, id).Scan(&vid)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
if !vid.Valid {
return "", nil
}
return vid.String, nil
}
// 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) {
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
}
// 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)
grace := now - SoftDeleteGraceDays*86400
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
}
// ReleaseCustomSlugs frees custom URLs so they can be reused:
// - pastes whose expires_at has passed (expired or soft-deleted/expired),
// - pastes created more than reservationDays days ago (custom URLs are a
// reservation, not permanent).
//
// It returns the number of pastes whose custom_slug was released.
func (s *Store) ReleaseCustomSlugs(reservationDays int) (int64, error) {
now := time.Now().Unix()
res, err := s.db.Exec(`UPDATE pastes SET custom_slug = NULL
WHERE custom_slug IS NOT NULL
AND (expires_at IS NOT NULL AND expires_at > 0 AND expires_at < ?
OR created_at < ?)`,
now, now-int64(reservationDays)*86400)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
if n > 0 {
log.Printf("released %d custom slug(s)", n)
}
return n, nil
}
func (s *Store) StartSweeper(every time.Duration, reservationDays int) {
go func() {
t := time.NewTicker(every)
for range t.C {
s.SweepExpired()
s.ReleaseCustomSlugs(reservationDays)
}
}()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func NullStrPtr(ns sql.NullString) *string {
if ns.Valid {
return &ns.String
}
return nil
}
// HardDelete removes a paste row entirely (deletion-token redeem).
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 {
_, 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
}
// DeleteCan removes an (empty/aborted) can row.
func (s *Store) DeleteCan(canID string) {
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
}
// InsertCanItem adds an item paste belonging to a can.
func (s *Store) InsertCanItem(canID, title, content, contentType string, language *string, expiresAt, binary *string, now int64) error {
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
_, err := s.db.Exec(`INSERT INTO pastes
(id, content, content_type, language, title, visibility, can_id, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
_ = expiresAt
_ = binary
return err
}
func (s *Store) GetCan(id string) (*CanRow, error) {
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at
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)
if err == sql.ErrNoRows {
return nil, nil
}
return &c, err
}
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PasteRow
for rows.Next() {
var r PasteRow
if err := rows.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// GenSlug is the exported slug generator.
func GenSlug(n int) string { return genSlug(n) }
// Exec runs a raw statement (test helper).
func (s *Store) Exec(query string, args ...any) (int64, error) {
res, err := s.db.Exec(query, args...)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
// QueryInt runs a query returning a single integer (test helper).
func (s *Store) QueryInt(query string, args ...any) int {
var n int
s.db.QueryRow(query, args...).Scan(&n)
return n
}
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"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'; 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 response: no security headers.
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 != "" {
t.Errorf("unexpected CSP %q on JSON response", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "" {
t.Errorf("unexpected Referrer-Policy %q on JSON response", got)
}
// Content type set after the first Write (as the inline can page does) is
// still picked up because headers are inspected post-handler.
lateh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html></html>"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}))
rec = httptest.NewRecorder()
lateh.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") {
t.Errorf("CSP = %q, want frame-ancestors 'none'", got)
}
}
+458
View File
@@ -0,0 +1,458 @@
/* Palette UI tokens (mirrors sketches/themes/tokens.css, midnight approved preset) */
:root {
--bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52;
--muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8;
--accent: #C4A8F0; --border: #42355C;
--radius-lg: 20px; --radius: 10px; --radius-sm: 999px;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace;
}
/* semantic status colors (dark preset values; light presets override below) */
:root {
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
--on-accent: #241B30; /* text placed on accent-colored backgrounds */
}
[data-preset="smooth"] {
--bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6;
--muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36;
--accent: #7A7796; --border: #DAD7E6;
--ok: #456F45; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #F6F5FA;
}
[data-preset="pastel-lavender"] {
--bg: #e6e0f5; --surface: #f1edfa; --surface-2: #cbb8e7;
--muted: #a99cc9; --muted-fg: #5f5390; --fg: #3E3059;
--accent: #806bb8; --border: #c4b6e0;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #f1edfa;
}
[data-preset="pastel-peach"] {
--bg: #ffe0d6; --surface: #fff0ea; --surface-2: #ffc4a8;
--muted: #d9a08c; --muted-fg: #7a4632; --fg: #4F2318;
--accent: #f9826c; --border: #ffc9b5;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #4F2318;
}
[data-preset="pastel-cloud"] {
--bg: #fff0f6; --surface: #fff7fb; --surface-2: #ffc8dd;
--muted: #d9b9c9; --muted-fg: #7d5670; --fg: #4A3355;
--accent: #a2d2ff; --border: #ffccd9;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #274a6b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--fg);
line-height: 1.45;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
font-size: 24.2px;
}
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 0 22px; height: 76px;
background: var(--surface); border-bottom: 1px solid var(--border);
}
.logo { font-weight: 700; font-size: 27.6px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); }
.logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; }
.topbar nav { display: flex; gap: 4px; }
.topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: var(--radius); font-size: 23.2px; }
.topbar nav a:hover { background: var(--surface-2); color: var(--fg); }
.topbar nav a.on { background: var(--accent); color: var(--bg); }
.topbar .spacer { flex: 1; }
.kbd { font-family: var(--font-mono); font-size: 18.9px; border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 6px; color: var(--muted-fg); }
.float {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
box-shadow:
0 1px 2px rgba(0, 0, 0, .10),
0 2px 6px rgba(0, 0, 0, .08),
0 8px 24px rgba(0, 0, 0, .07);
overflow: hidden;
}
/* new paste page */
.deck {
display: grid; grid-template-columns: 1fr 300px; gap: 16px;
padding: 16px 20px 20px; height: calc(100vh - 76px);
max-width: 1400px; margin: 0 auto;
}
.pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; }
.side-section { padding: 14px 16px; flex-shrink: 0; }
.side-section h3 { font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
.pane-l-col { display: flex; flex-direction: column; gap: 14px; min-height: 0; }
.pane-l-head { flex-shrink: 0; }
.editor-head {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
}
.editor-head input {
border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 23.2px; flex: 1;
}
.editor-head select {
border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg);
border-radius: var(--radius); padding: 4px 10px; font: inherit; font-size: 21.6px; cursor: pointer;
}
/* shared code line metrics (#50): gutter + code must share one line box */
:root { --code-lh: 1.7; --code-fs: 21.6px; }
.editor-wrap { flex: 1; display: flex; min-height: 0; }
.gutter {
padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono);
font-size: var(--code-fs); line-height: var(--code-lh); user-select: none; white-space: pre; overflow: hidden;
border-right: 1px solid var(--border);
}
.editor {
flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 21.6px; line-height: 1.7;
white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg);
resize: none; width: 100%;
}
.editor::placeholder { color: var(--muted); }
.actionbar {
display: flex; align-items: center; gap: 14px;
padding: 10px 4px;
}
.actionbar .btn { padding: 10px 26px; }
.btn {
background: var(--accent); color: var(--bg); border: none; cursor: pointer;
padding: 8px 18px; border-radius: var(--radius); font: inherit; font-size: 22.4px; font-weight: 600;
}
.btn:hover { filter: brightness(1.08); }
.hint { font-size: 20.7px; color: var(--muted-fg); }
.hint b { color: var(--fg); font-weight: 550; }
.seg { display: flex; flex-direction: column; gap: 2px; }
.seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: var(--radius); cursor: pointer; font-size: 22.4px; }
.seg label:hover { background: var(--surface-2); }
.seg input { accent-color: var(--accent); }
.toggle { display: flex; align-items: center; gap: 8px; font-size: 22.4px; cursor: pointer; padding: 5px 8px; border-radius: var(--radius); }
.toggle:hover { background: var(--surface-2); }
.toggle input { accent-color: var(--accent); }
.deck .row { display: flex; justify-content: space-between; align-items: center; font-size: 22.4px; padding: 4px 0; }
.row input[type="text"] {
border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 21.6px; width: 130px;
}
.created-banner {
display: none; padding: 10px 16px; font-size: 22.4px; background: var(--surface-2);
border-bottom: 1px solid var(--border); word-break: break-all;
}
.created-banner a { color: var(--accent); }
/* 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); }
.tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; }
.paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
.stats-pill { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.stats-head { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 14px 18px; cursor: pointer; text-align: left; }
.stats-head:hover { color: var(--fg); background: var(--surface-2); }
.stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; }
.stats-pill.open .stats-chev { transform: rotate(180deg); }
.stats-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: .01em; }
@media (max-width: 640px) { .stats-summary { white-space: normal; word-break: break-word; } }
.stats-body { border-top: 1px solid var(--border); }
.stats-grid { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; padding: 12px 16px; font-size: 20.7px; }
.stats-k { color: var(--muted-fg); }
.stats-v { color: var(--fg); word-break: break-all; }
.meta-bar .spacer { flex: 1; }
.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; }
.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; }
.iconbtn:hover { color: var(--fg); border-color: var(--muted); }
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
.code-head {
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
border-bottom: 1px solid var(--border); font-size: 21.6px; color: var(--muted-fg);
}
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.code {
font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh);
padding: 14px 0; display: flex; overflow-x: auto;
}
.code .gutter { flex-shrink: 0; }
/* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */
.code .gutter { padding-top: 0; padding-bottom: 0; }
.codebody { padding: 0 18px; white-space: pre; }
/* syntax highlight tokens (#1) */
.tok-kw { color: #c792ea; }
.tok-str { color: #a5e075; }
.tok-num { color: #f78c6c; }
.tok-com { color: #6a737d; font-style: italic; }
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 20.7px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
/* history */
.page { max-width: 1200px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.head-row { display: flex; align-items: baseline; gap: 14px; }
.head-row h1 { font-size: 34.5px; font-weight: 600; }
.search {
display: flex; align-items: center; gap: 8px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 14px; width: 260px;
}
.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 22.4px; width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 22.4px; table-layout: fixed; }
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; }
tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); }
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.slug:hover { color: var(--accent); }
.badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; }
.badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; }
.pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 21.6px; color: var(--muted-fg); }
.pager .pg { display: flex; gap: 6px; }
.pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 4px 11px; font: inherit; font-size: 21.6px; cursor: pointer; }
.pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); }
.pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.pager button:disabled { opacity: .4; cursor: default; }
.empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 22.4px; }
/* unlock */
.center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); }
.center .float { width: 400px; max-width: 100%; }
.inner { padding: 28px; text-align: center; }
.lockring {
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 41.4px;
}
.inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
.inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
.pwinput {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
letter-spacing: .12em;
}
.pwinput:focus { border-color: var(--accent); }
.center .btn { width: 100%; margin-top: 12px; }
.err { display: none; margin-top: 12px; font-size: 21.6px; color: #ff8fa3; }
.center .foot { font-size: 20.7px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
.btn-icon {
padding: 4px 8px; font-size: 24.2px; line-height: 1; overflow: visible;
display: inline-flex; align-items: center; justify-content: center;
min-width: 40px; height: 36px;
}
/* selection controls: pill-style selected states (#14) */
.seg label, .toggle {
border: 1px solid transparent;
transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.seg label:hover, .toggle:hover {
background: var(--surface-2);
color: var(--fg);
}
.seg label:has(input:checked),
.toggle:has(input:checked) {
background: var(--surface-2);
border-color: var(--accent);
color: var(--fg);
border-radius: var(--radius-sm);
}
.seg label:has(input:focus-visible),
.toggle:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.seg input, .toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; }
/* toast (#19) */
.toast {
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(8px);
background: var(--surface-2); color: var(--fg); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 6px 18px; font-size: 20.7px;
opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,.25);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* status variants (#16) */
.toast.success { border-color: var(--ok); color: var(--ok); }
.toast.error { border-color: var(--err); color: var(--err); }
/* protection section rhythm (#20) */
.protect { display: flex; flex-direction: column; gap: 2px; }
.protect .pw-row { padding: 2px 8px 4px; }
.pw-field {
display: flex; align-items: center; gap: 2px; width: 100%;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
}
.pw-field:focus-within { border-color: var(--accent); }
.pw-field input {
flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--fg);
font: inherit; font-size: 21.6px; padding: 7px 12px; letter-spacing: .08em;
}
.pw-field input::placeholder { color: var(--muted); letter-spacing: normal; }
.pw-field .reveal {
background: none; border: none; color: var(--muted-fg); cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0 10px; height: 100%;
flex-shrink: 0;
}
.pw-field .reveal:hover { color: var(--fg); }
.pw-field .reveal .eye-slash { display: none; }
.pw-field .reveal.off .eye-slash { display: block; }
.pw-field svg { width: 20px; height: 20px; display: block; }
/* custom URL input (#22) */
.deck .row input[type="text"] { width: 100%; }
.custom-input {
display: block; width: 100%;
border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 12px;
background: var(--bg); color: var(--fg); font: inherit; font-size: 21.6px; outline: none;
}
.custom-input:focus { border-color: var(--accent); }
.custom-input::placeholder { color: var(--muted); }
/* btn-icon svg (#24) */
.btn-icon svg { width: 20px; height: 20px; display: block; }
/* search spinner (#32) */
.search-spinner {
width: 16px; height: 16px; flex-shrink: 0;
border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%;
animation: spin .8s linear infinite; visibility: hidden;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* unlock redesign (#27) */
.unlock-card .pw-field { margin: 18px 0 4px; text-align: left; }
.unlock-card .pw-field input { text-align: left; }
.unlock-err { margin-top: 10px; font-size: 20.7px; color: #ff8fa3; }
/* 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 .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.sorted.asc .sort-ind { border-bottom: 6px solid var(--accent); }
th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
/* ============================================================
Consistency audit (#18) — shared tokens across inputs, buttons,
headings. Visual-only, no behavior change.
============================================================ */
/* shared text-input treatment: .search input, .pw-field input, #title, #custom */
.search input,
.pw-field input,
.editor-head input#title,
.custom-input,
.row input[type="text"] {
font-size: 21.6px;
color: var(--fg);
}
.search input::placeholder,
.pw-field input::placeholder,
.editor-head input#title::placeholder,
.custom-input::placeholder {
color: var(--muted);
}
.custom-input { border-radius: var(--radius); }
/* buttons (incl. icon-only variants) share one radius + focus ring */
.btn-icon, .iconbtn {
border-radius: var(--radius);
transition: color .12s ease, border-color .12s ease, background .12s ease;
}
.iconbtn:focus-visible, .btn:focus-visible, .btn-icon:focus-visible, .pager button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* in-place copy success feedback (#53) */
.iconbtn.ok, .btn.ok {
color: var(--ok);
border-color: var(--ok);
}
/* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em;
}
/* consistent card padding scale: 12px 18px for wide card heads/bodies */
.settings-head { padding: 12px 18px; }
.settings-body { padding: 16px 18px; }
/* topbar: Git external-link arrow (#56) */
.topbar nav a .ext { width: 14px; height: 14px; margin-left: 4px; opacity: .55; vertical-align: -1px; }
@media (max-width: 640px) {
body { font-size: 16px; }
/* topbar: tighten so logo + nav + gear fit */
.topbar { gap: 10px; padding: 0 12px; height: 56px; }
.logo { font-size: 17px; white-space: nowrap; }
.logo em { display: none; }
.topbar nav { gap: 2px; flex-shrink: 0; }
.topbar nav a { padding: 5px 8px; font-size: 15px; }
.iconbtn.gear { padding: 4px 7px; flex-shrink: 0; }
.iconbtn.gear svg { width: 18px; height: 18px; }
/* new paste: stack editor above sidebar, natural page height */
.deck {
display: flex; flex-direction: column;
height: auto; min-height: calc(100vh - 56px);
padding: 12px; gap: 12px;
}
.pane-l-col { order: 0; }
.pane-r { order: 1; overflow-y: visible; }
.editor-wrap { min-height: 45vh; }
.editor { font-size: 15px; }
.gutter { font-size: 15px; }
.editor-head input { min-width: 0; font-size: 16px; }
.editor-head select { max-width: 120px; font-size: 14px; }
.actionbar { flex-wrap: wrap; }
.actionbar .btn { padding: 12px 22px; }
.hint { font-size: 13px; }
/* history: horizontal-scroll table inside its card */
.page { padding: 12px; }
.head-row { flex-wrap: wrap; }
.head-row h1 { font-size: 22px; }
.search { width: 100%; }
.search input { font-size: 16px; }
.float { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { min-width: 720px; }
th { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
td { padding: 8px 10px; font-size: 14px; }
.pager { flex-wrap: wrap; gap: 8px; font-size: 13px; }
/* paste view */
.paste-title-bar { padding: 10px 12px; gap: 8px; }
.paste-title-bar h1 { font-size: 18px; }
.slug { font-size: 13px; word-break: break-all; }
.stats-head { font-size: 13px; }
.stats-grid { font-size: 13px; padding: 10px 12px; }
.code { font-size: 13px; }
.codebody { padding: 0 12px; }
.footnote { font-size: 12px; padding: 8px 12px; gap: 10px; }
.created-banner { font-size: 13px; }
.iconbtn { font-size: 13px; padding: 6px 10px; }
/* unlock card */
.center { height: auto; min-height: calc(100vh - 56px); padding: 16px; }
.center .float { width: 100%; }
.inner { padding: 20px 16px; }
.inner h1 { font-size: 20px; }
.inner .sub { font-size: 14px; }
.unlock-err { font-size: 13px; }
.center .foot { font-size: 13px; }
}
+163
View File
@@ -0,0 +1,163 @@
// Shared table logic for history (/api/public) and saved (/api/mine) pages (#57).
// Provides: live search, client-side sort with indicators, row rendering via
// a page-supplied rowHtml(), pagination state, and row click-through.
const PaletteTable = (() => {
const $ = id => document.getElementById(id);
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; };
const fmtSize = n => { if (n == null) return 'none'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; };
const ago = ts => {
const s = Math.floor(Date.now()/1000) - ts;
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
};
const sortVal = (it, k) => {
let v = it[k];
if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase();
if (k === 'language') v = (v == null || v === '') ? 'text' : String(v).toLowerCase();
if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v;
return v == null ? null : v;
};
function init(opts) {
// opts: {endpoint, perPage, hasPager, rowHtml(it), emptyFiltered, emptyAll}
const state = { filter: '', sortKey: null, sortDir: 1, page: 1, total: 0 };
let timer = null;
function sortItems(items) {
if (!state.sortKey) return items;
const k = state.sortKey, dir = state.sortDir;
return items.slice().sort((a, b) => {
const va = sortVal(a, k), vb = sortVal(b, k);
const na = va == null, nb = vb == null;
if (na && nb) return 0;
if (na) return 1;
if (nb) return -1;
if (va < vb) return -1 * dir;
if (va > vb) return 1 * dir;
return (a.created_at || 0) < (b.created_at || 0) ? 1 : -1;
});
}
function matches(it) {
if (!state.filter) return true;
const f = state.filter.toLowerCase();
return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f) ||
(it.custom_slug || '').toLowerCase().includes(f);
}
function renderSortIndicators() {
document.querySelectorAll('th.sortable').forEach(th => {
th.classList.toggle('sorted', th.dataset.sort === state.sortKey);
th.classList.toggle('asc', th.dataset.sort === state.sortKey && state.sortDir === 1);
th.classList.toggle('desc', th.dataset.sort === state.sortKey && state.sortDir === -1);
});
}
async function load() {
const spinner = $('search-spinner');
if (spinner) spinner.style.visibility = 'visible';
try {
const filtered = state.filter.length > 0;
const off = (state.page - 1) * opts.perPage;
const url = (filtered || state.sortKey)
? 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();
state.total = data.total;
let items = filtered ? data.items.filter(matches) : data.items;
items = sortItems(items);
const count = $('count');
if (count) count.textContent = filtered
? items.length.toLocaleString() + ' matches (of ' + state.total.toLocaleString() + ' total)'
: state.total.toLocaleString() + ' total';
const rows = $('rows'), empty = $('empty');
if (!items.length) {
rows.innerHTML = '';
empty.style.display = 'block';
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
} else {
empty.style.display = 'none';
rows.innerHTML = items.map(opts.rowHtml).join('');
}
const pager = $('pg'), showing = $('showing');
if (opts.hasPager && pager && showing) {
const pages = Math.max(1, Math.ceil(state.total / opts.perPage));
if (filtered || state.sortKey) {
showing.textContent = state.sortKey
? 'Sorted by ' + state.sortKey + ' (' + (state.sortDir === 1 ? 'ascending' : 'descending') + ') · ' + items.length.toLocaleString() + ' of ' + state.total.toLocaleString()
: 'Showing ' + items.length.toLocaleString() + ' matches for "' + state.filter + '"';
pager.innerHTML = '';
} else {
showing.textContent = state.total === 0 ? 'Nothing here yet' :
`Showing ${off+1}${Math.min(off+opts.perPage, state.total)} of ${state.total.toLocaleString()} · page ${state.page} of ${pages}`;
const btns = [];
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', state.page-1, {dis: state.page===1});
const win = new Set([1, 2, state.page-1, state.page, state.page+1, pages]);
let last = 0;
for (let i = 1; i <= pages; i++) {
if (win.has(i)) {
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
add(String(i), i, {on: i===state.page});
last = i;
}
}
add('', state.page+1, {dis: state.page===pages});
pager.innerHTML = btns.join('');
}
} else if (showing) {
showing.textContent = '';
}
renderSortIndicators();
} finally {
if (spinner) spinner.style.visibility = 'hidden';
}
}
document.querySelector('thead').addEventListener('click', e => {
const th = e.target.closest('th.sortable');
if (!th) return;
const k = th.dataset.sort;
if (state.sortKey === k) { state.sortDir = -state.sortDir; } else { state.sortKey = k; state.sortDir = 1; }
renderSortIndicators();
load();
});
const rows = $('rows');
if (rows) rows.addEventListener('click', e => {
const tr = e.target.closest('tr.row[data-href]');
if (!tr || e.target.closest('a') || e.target.closest('button')) return;
window.location.href = tr.dataset.href;
});
const pg = $('pg');
if (pg) pg.addEventListener('click', e => {
const b = e.target.closest('button[data-p]');
if (!b || b.disabled) return;
state.page = parseInt(b.dataset.p);
load();
window.scrollTo(0, 0);
});
const filter = $('filter');
if (filter) filter.addEventListener('input', e => {
clearTimeout(timer);
timer = setTimeout(() => {
state.filter = e.target.value.trim();
state.page = 1;
load();
}, 200);
});
return { load, state, esc, fmtSize, ago };
}
return { init, esc, fmtSize, ago };
})();
+107
View File
@@ -0,0 +1,107 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Admin</h1>
</div>
<div class="settings-body">
<p>Enter the admin key to manage server settings. The key is kept in
sessionStorage for this tab only and is sent as a request header — it is
never stored in a cookie, so it will not accompany normal paste requests.</p>
<form id="admin-key-form">
<label for="admin-key">Admin key</label><br>
<input type="password" id="admin-key" autocomplete="off" style="width:100%">
<button type="submit">Unlock</button>
<span id="admin-key-status"></span>
</form>
<div id="admin-panel" style="display:none">
<h2>Settings</h2>
<form id="admin-settings-form">
<table>
<tr><td>Rate-limit burst</td><td><input type="number" id="rl-burst" min="1" step="1"></td></tr>
<tr><td>Rate-limit refill per minute</td><td><input type="number" id="rl-refill" min="0.1" step="0.1"></td></tr>
<tr><td>Max content bytes</td><td><input type="number" id="max-content" min="1" step="1"></td></tr>
<tr><td>Default expiry</td><td><input type="text" id="default-expiry" placeholder="e.g. 168h, 30m, 0 = never"></td></tr>
<tr><td>Custom URL reservation days</td><td><input type="number" id="slug-days" min="1" step="1"></td></tr>
<tr><td>Burn viewer window (minutes)</td><td><input type="number" id="burn-window" min="1" step="1"></td></tr>
</table>
<button type="submit">Save</button>
<span id="admin-save-status"></span>
</form>
</div>
</div>
</div>
</div>
<script>
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
opts.headers = { 'X-Admin-Key': key() };
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.style.display = '';
});
}
function showLock() {
panel.style.display = 'none';
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
sessionStorage.setItem(KEY, keyInput.value);
api('/admin/api/settings').then(function (r) {
if (r.status === 200) {
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
if (key()) loadSettings();
})();
</script>
{{template "foot" .}}
+21
View File
@@ -0,0 +1,21 @@
{{define "foot"}}<script>
// live relative-time counters (#46): tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
</script>{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Public pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:96px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="view_count" class="sortable"><span class="sort-ind"></span>Views</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</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>
const t = PaletteTable.init({
endpoint: '/api/public',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.',
});
t.load();
setInterval(t.load, 30000); // auto-refresh history every 30s
</script>
{{template "foot" .}}
+28
View File
@@ -0,0 +1,28 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
<script>
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
(function () {
var t = new URLSearchParams(location.search).get('theme');
if (t) document.documentElement.dataset.preset = t;
})();
</script>
{{end}}
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</em></a>
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
<a href="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">
<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>
{{end}}
+73
View File
@@ -0,0 +1,73 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Saved pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:150px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
</div>
</div>
<script src="/static/table.js"></script>
<script>
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 50,
hasPager: false,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
</script>
{{template "foot" .}}
+267
View File
@@ -0,0 +1,267 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="deck">
<div class="pane-l-col">
<div class="float pane-l-head">
<div class="editor-head">
<input id="title" placeholder="Title">
<select id="language">
<option value="">auto</option>
<option>go</option><option>python</option><option>javascript</option><option>typescript</option>
<option>rust</option><option>c</option><option>cpp</option><option>java</option><option>csharp</option>
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
<option>html</option><option>css</option><option>xml</option><option>php</option>
<option>ruby</option><option>perl</option><option>lua</option><option>dockerfile</option>
<option>toml</option><option>ini</option><option>diff</option>
<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>
</div>
</div>
<div class="float editor-wrap">
<div class="gutter" id="gutter">1</div>
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
</div>
<div class="created-banner" id="created"></div>
<div class="actionbar">
<span class="hint">Ctrl+Enter to create</span>
<div class="spacer" style="flex:1"></div>
<button class="btn" id="create">Create</button>
</div>
</div>
<div class="pane-r">
<div class="float side-section">
<h3>Expiry</h3>
<div class="seg">
<label><input type="radio" name="exp" value=""> Never</label>
<label><input type="radio" name="exp" value="1h"> 1 hour</label>
<label><input type="radio" name="exp" value="24h"> 1 day</label>
<label><input type="radio" name="exp" value="168h" checked> 1 week</label>
<label><input type="radio" name="exp" value="720h"> 30 days</label>
<label><input type="radio" name="exp" value="custom"> Custom</label>
</div>
<div class="pw-row" id="customexp-row" style="display:none">
<input type="number" id="expnum" min="1" style="width:80px" placeholder="90">
<select id="expunit">
<option value="m">minutes</option>
<option value="h" selected>hours</option>
<option value="d">days</option>
<option value="w">weeks</option>
<option value="mo">months</option>
</select>
<div class="hint" id="customexp-err" style="display:none; color:var(--danger, #c0392b); margin-top:6px;"></div>
</div>
</div>
<div class="float side-section">
<h3>Protection</h3>
<div class="protect">
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<div class="pw-row" id="pwrow" style="display:none"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<div class="pw-row" id="burnrow" style="display:none"><label class="hint" style="font-size:19px;">Readable <input type="number" id="burnreads" min="1" value="1" style="width:64px"> times</label></div>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<input type="text" id="custom" class="custom-input" placeholder="/my-snippet">
<div class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
</div>
<div class="float side-section" id="result-card" style="display:none">
<h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all">empty</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
function updateGutter() {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
updateGutter();
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
$('burn').addEventListener('change', e => { $('burnrow').style.display = e.target.checked ? 'block' : 'none'; });
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
$('customexp-row').style.display = document.querySelector('input[name="exp"]:checked').value === 'custom' ? 'block' : 'none';
$('customexp-err').style.display = 'none';
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.style.display = 'block';
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.style.display = 'block';
return null;
}
err.style.display = 'none';
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
}
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
function showResult(html, isError) {
$('result').innerHTML = html;
$('result').dataset.token = isError ? '' : ($('result').dataset.token || '');
$('result-card').style.display = 'block';
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value;
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showResult('Error: ' + (data.error || res.status), true);
toast('Create failed', 'error');
return;
}
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
const dest = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
// password-protected: unlock now with the password we already have (#26)
if ($('haspw').checked && data.id) {
const fd = new FormData();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
await fetch('/' + data.id, {method: 'POST', body: fd});
} catch(e) {}
}
// show the paste
location.href = dest;
}
$('create').addEventListener('click', create);
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.style.display = 'none';
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
</script>
{{template "foot" .}}
+84
View File
@@ -0,0 +1,84 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="paste-title-bar">
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
<div class="spacer"></div>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
</div>
</div>
<div class="float">
<div class="stats-pill" id="stats-pill">
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false" onclick="toggleStats()">
<svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
<span class="stats-summary">{{.StatsSummary}}</span>
</button>
<div class="stats-body" id="stats-body" hidden>
<div class="stats-grid">
<span class="stats-k">Language</span><span class="stats-v">{{if .Language}}{{.Language}}{{else}}text{{end}}</span>
<span class="stats-k">Size</span><span class="stats-v">{{.SizeHuman}} ({{.LineCount}} lines)</span>
<span class="stats-k">Views</span><span class="stats-v">{{.ViewCount}}</span>
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
{{if .ExpiresAt}}<span class="stats-k">Expires</span><span class="stats-v">in {{.ExpiresIn}}</span>{{end}}
<span class="stats-k">Password</span><span class="stats-v">{{if .HasPassword}}protected{{else}}none{{end}}</span>
{{if .BurnAfterRead}}{{if .ReadsLimit}}{{with .ReadsLeftN}}<span class="stats-k">Reads left</span><span class="stats-v">{{.}} of {{$.ReadsTotal}}</span>{{end}}{{else}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}{{end}}
{{if .CustomSlug}}<span class="stats-k">Custom URL</span><span class="stats-v">/{{.CustomSlug}}</span>{{end}}
<span class="stats-k">Visibility</span><span class="stats-v">{{.Visibility}}</span>
</div>
</div>
</div>
</div>
{{if .JustCreated}}
<div class="float">
<div class="created-banner" style="display:block">
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
</div>
</div>
{{end}}
<div class="float">
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
</div>
</div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script>
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
// in-place success feedback (#53)
if (btn) {
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
}
}
function redeem(token) {
if (!confirm('Hard delete this paste immediately?')) return;
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
</script>
{{template "foot" .}}
+13
View File
@@ -0,0 +1,13 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Settings</h1>
</div>
<div class="settings-body">
<p>Settings are under construction.</p>
</div>
</div>
</div>
{{template "foot" .}}
+29
View File
@@ -0,0 +1,29 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float unlock-card">
<div class="inner">
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<h1>This paste is locked</h1>
<p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p>
<form method="post" action="">
<div class="pw-field">
<input type="password" name="password" id="password" placeholder="Password" autocomplete="current-password" autofocus>
<button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
{{if .Wrong}}<p class="unlock-err">Wrong password. Try again.</p>{{end}}
<button class="btn" type="submit">Unlock</button>
</form>
</div>
<div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div>
</div>
</div>
<script>
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
</script>
{{template "foot" .}}
+316
View File
@@ -0,0 +1,316 @@
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
// the admin page. Templates and static assets are embedded in this package.
package web
import (
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
langpkg "palette/internal/lang"
"palette/internal/store"
)
//go:embed templates/*.html
var tmplFS embed.FS
//go:embed static
var staticFS embed.FS
type UI struct {
tmpl *template.Template
}
func New() (*UI, error) {
funcs := template.FuncMap{
"humanSize": humanSize,
}
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
if err != nil {
return nil, err
}
return &UI{tmpl: t}, nil
}
func humanSize(n int) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
if n < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(n)/1024)
}
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
func (u *UI) StaticHandler() http.Handler {
sub, _ := fs.Sub(staticFS, "static")
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
}
func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "template error: "+err.Error(), 500)
}
}
// #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
// actually submitting the correct password for that paste.
var unlockSecret = resolveUnlockSecret()
func resolveUnlockSecret() []byte {
if v := os.Getenv("PALETTE_UNLOCK_SECRET"); v != "" {
return []byte(v)
}
b := make([]byte, 32)
if _, err := cryptorand.Read(b); err != nil {
log.Fatal("cannot generate unlock secret: ", err)
}
return b
}
func unlockToken(id string) string {
mac := hmac.New(sha256.New, unlockSecret)
mac.Write([]byte("unlock:" + id))
return hex.EncodeToString(mac.Sum(nil))
}
func agoString(ts int64) string {
s := time.Now().Unix() - ts
switch {
case s < 60:
return fmt.Sprintf("%ds ago", s)
case s < 3600:
return fmt.Sprintf("%dm ago", s/60)
case s < 86400:
return fmt.Sprintf("%dh ago", s/3600)
default:
return fmt.Sprintf("%dd ago", s/86400)
}
}
func expiryString(expiresAt int64) string {
s := expiresAt - time.Now().Unix()
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)
}
}
// Handlers is the set of store callbacks the web pages need. The web package
// renders HTML; all queries go through the store.
type Handlers struct {
UI *UI
Store *store.Store
ViewerID func(r *http.Request) string
BurnWindowMin func() int
RateLimitOK func(id string, r *http.Request) bool // per-paste unlock limiter
}
func (h *Handlers) rateLimitUnlock(id string, r *http.Request) bool {
if h.RateLimitOK != nil {
return h.RateLimitOK(id, r)
}
return true
}
func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSecs))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(429)
w.Write([]byte(`{"error":"rate limit exceeded"}`))
}
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
lines := strings.Count(row.Content, "\n") + 1
gutter := ""
for i := 1; i <= lines; i++ {
gutter += fmt.Sprintf("%d\n", i)
}
expIn := ""
if row.ExpiresAt.Valid {
expIn = expiryString(row.ExpiresAt.Int64)
}
lang := row.Language.String
if lang == "" {
lang = "text"
}
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt))
data := map[string]any{
"Page": "paste",
"ID": row.ID,
"Title": row.Title.String,
"Language": row.Language.String,
"StatsSummary": summary,
"SizeHuman": humanSize(len(row.Content)),
"HasPassword": row.PasswordHash.Valid,
"BurnAfterRead": row.BurnAfterRead,
"CustomSlug": row.CustomSlug.String,
"ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text
"ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines,
"SizeBytes": len(row.Content),
"CreatedAgo": agoString(row.CreatedAt),
"CreatedAtUnix": row.CreatedAt,
"ViewCount": row.ViewCount,
"Visibility": row.Visibility,
"ExpiresAt": row.ExpiresAt.Valid,
"ExpiresIn": expIn,
"DeletionToken": deletionToken,
"ReadsLimit": row.ReadsLimit.Valid,
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
"ReadsTotal": int(row.ReadsLimit.Int64),
"JustCreated": justCreated,
"Host": "this host",
}
h.renderPage(w, "paste.html", data)
}
// HandlePasteView renders the paste view; supports both ID and custom slug.
func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
row, err := h.Store.GetPaste(id)
if err != nil {
http.Error(w, "db error", 500)
return
}
if row == nil {
http.NotFound(w, r)
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
http.Error(w, "paste expired", 404)
return
}
if row.PasswordHash.Valid {
// if a password was submitted via unlock form, verify and set cookie for this paste
if r.Method == http.MethodPost {
if !h.rateLimitUnlock(row.ID, r) {
h.writeRateLimited(w, 60)
return
}
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && store.CheckPassword(row.PasswordHash.String, pw) {
// #34: the unlock cookie must be bound to this specific paste and
// unforgable. A static value ("1") let anyone bypass the password
// by setting pw_<id>=1 for any paste id. The token is an HMAC of
// the paste id under the server's random secret.
http.SetCookie(w, &http.Cookie{
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
// re-render without lock, or redirect if ?next= was given (#26)
if next := r.FormValue("next"); next != "" {
// only allow same-origin relative paths
if len(next) > 0 && next[0] == '/' && !strings.HasPrefix(next, "//") {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
}
h.renderPaste(w, row, false, "", nil)
return
}
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
return
}
// check cookie — must carry the valid per-paste unlock token (#34)
c, err := r.Cookie("pw_" + row.ID)
if err != nil || c.Value != unlockToken(row.ID) {
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
return
}
}
justCreated := r.URL.Query().Get("created") == "1"
token := r.URL.Query().Get("token")
if justCreated && token != "" {
// one-time display of the deletion token via the created banner
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
// 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.
if !justCreated {
h.Store.IncrementViews(row.ID)
}
// #49: burn-after-N-reads budget (per-viewer dedupe window).
// Just-created first render does not count as a read for the creator.
if !justCreated {
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
if !admitted { // #58: lost the burn claim; do not render content
http.NotFound(w, r)
return
}
h.renderPaste(w, row, false, "", rem)
return
}
// only pass the token to the template right after creation
h.renderPaste(w, row, true, token, nil)
}
// HandleNewPage serves /new.
func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
}
// HandleHistoryPage serves /history.
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
}
// HandleSettingsPage serves /settings.
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "settings.html", map[string]any{"Page": "settings"})
}
// HandleMinePage serves /mine.
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
}
// HandleAdminPage serves /admin.
func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "admin.html", map[string]any{"Page": "admin"})
}
// 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) {
next.ServeHTTP(w, r)
h := w.Header()
if strings.HasPrefix(h.Get("Content-Type"), "text/html") {
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
}
})
}
-571
View File
@@ -1,571 +0,0 @@
package main
import (
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
_ "modernc.org/sqlite"
)
//go:embed web/templates/* web/static/*
var webFS embed.FS
const (
softDeleteGraceDays = 7
)
type Config struct {
Addr string
DBPath string
MaxTextBytes int64
MaxItemBytes int64
}
type Paste struct {
ID string `json:"id"`
CustomSlug *string `json:"custom_slug,omitempty"`
Content string `json:"content"`
ContentType string `json:"content_type"`
Language *string `json:"language,omitempty"`
Title *string `json:"title,omitempty"`
Password *string `json:"password,omitempty"`
ExpiresIn *string `json:"expires_in,omitempty"`
BurnAfterRead bool `json:"burn_after_read,omitempty"`
Visibility string `json:"visibility"`
CanID *string `json:"can_id,omitempty"`
CreatedAt int64 `json:"created_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
ViewCount int `json:"view_count"`
DeletionToken string `json:"-"`
}
type PasteRow struct {
ID string
CustomSlug sql.NullString
Content string
ContentType string
Language sql.NullString
Title sql.NullString
PasswordHash sql.NullString
ExpiresAt sql.NullInt64
BurnAfterRead bool
Visibility string
CanID sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ViewCount int
Size int
DeletionToken sql.NullString
}
type CanRow struct {
ID string
Title sql.NullString
Visibility string
PasswordHash sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64
}
type Store struct {
db *sql.DB
}
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS pastes (
id TEXT PRIMARY KEY,
custom_slug TEXT UNIQUE,
content TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'text/plain',
language TEXT,
title TEXT,
password_hash TEXT,
expires_at INTEGER,
burn_after_read INTEGER DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'public',
can_id TEXT,
created_at INTEGER NOT NULL,
deleted_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0,
deletion_token TEXT
);
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS paste_cans (
id TEXT PRIMARY KEY,
title TEXT,
description TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
password_hash TEXT,
created_at INTEGER NOT NULL,
deleted_at INTEGER,
expires_at INTEGER
);
`)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
return err
}
var slugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
var httpClient = &http.Client{}
func genSlug(n int) string {
b := make([]byte, n)
_, _ = cryptorandRead(b)
for i := range b {
b[i] = slugAlphabet[int(b[i])%len(slugAlphabet)]
}
return string(b)
}
// cryptorandRead wraps crypto/rand
func cryptorandRead(b []byte) (int, error) {
return cryptoRead(b)
}
func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
id := genSlug(6)
now := time.Now().Unix()
var expiresAt *int64
if p.ExpiresIn != nil && *p.ExpiresIn != "" {
d, err := time.ParseDuration(*p.ExpiresIn)
if err != nil {
return nil, fmt.Errorf("invalid expires_in: %w", err)
}
t := now + int64(d.Seconds())
expiresAt = &t
}
var pwHash *string
if p.Password != nil && *p.Password != "" {
h, err := hashPassword(*p.Password)
if err != nil {
return nil, err
}
pwHash = &h
}
if p.CustomSlug != nil && *p.CustomSlug != "" {
slug := *p.CustomSlug
if err := ValidateCustomSlug(slug); err != nil {
return nil, err
}
taken, err := s.SlugTaken(slug)
if err != nil {
return nil, err
}
if taken {
return nil, errSlugTaken
}
}
visibility := p.Visibility
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
return nil, errors.New("visibility must be public or unlisted")
}
contentType := p.ContentType
if contentType == "" {
contentType = "text/plain"
}
var slugVal *string
if p.CustomSlug != nil && *p.CustomSlug != "" {
slugVal = p.CustomSlug
}
p.DeletionToken = genDeletionToken()
_, err := s.db.Exec(`INSERT INTO pastes
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken)
if err != nil {
return nil, err
}
p.ID = id
p.CreatedAt = now
p.ExpiresAt = expiresAt
p.Visibility = visibility
return p, nil
}
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
var r PasteRow
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
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)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []PasteRow
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 {
return nil, 0, err
}
r.CustomSlug = cs
r.Language = lang
r.Title = title
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)
return out, total, 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
}
func (s *Store) IncrementViews(id string) {
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
}
// 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)
grace := now - softDeleteGraceDays*86400
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
}
func (s *Store) StartSweeper(every time.Duration) {
go func() {
t := time.NewTicker(every)
for range t.C {
s.SweepExpired()
}
}()
}
func hashPassword(pw string) (string, error) {
// argon2id
return argon2idHash(pw)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func nullStrPtr(ns sql.NullString) *string {
if ns.Valid {
return &ns.String
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
type apiServer struct {
store *Store
cfg Config
}
func (a *apiServer) routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
// API
r.Route("/api", func(r chi.Router) {
r.Post("/pastes", a.handleCreatePaste)
r.Get("/pastes/{id}", a.handleGetPaste)
r.Delete("/pastes/{id}", a.handleDeletePaste)
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic)
r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
})
// can page
r.Get("/can/{id}", a.handleCanPage)
// raw
r.Get("/raw/{id}", a.handleRaw)
// web pages
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
r.Get("/new", a.handleNewPage)
r.Get("/history", a.handleHistoryPage)
r.Handle("/static/*", staticHandler())
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
r.Get("/{id}", a.handlePasteView)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not found")
})
return r
}
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
var p Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeErr(w, 400, "invalid json body")
return
}
if strings.TrimSpace(p.Content) == "" {
writeErr(w, 400, "content is required")
return
}
if int64(len(p.Content)) > a.cfg.MaxTextBytes {
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes))
return
}
created, err := a.store.CreatePaste(&p)
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 201, map[string]any{
"id": created.ID,
"deletion_token": created.DeletionToken,
"url": "/" + created.ID,
"raw_url": "/raw/" + created.ID,
"api_url": "/api/pastes/" + created.ID,
"expires_at": created.ExpiresAt,
"created_at": created.CreatedAt,
})
}
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil {
writeErr(w, 500, "db error")
return
}
if row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
writeErr(w, 404, "paste expired")
return
}
if row.PasswordHash.Valid {
// require password via header or query
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
pw = r.URL.Query().Get("password")
}
if pw == "" || !checkPassword(row.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
nullPtr := func(ns sql.NullString) *string {
if ns.Valid {
return &ns.String
}
return nil
}
a.store.maybeBurn(row)
writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt,
"view_count": row.ViewCount, "visibility": row.Visibility,
})
}
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
if err := a.store.SoftDelete(row.ID); err != nil {
writeErr(w, 500, "db error")
return
}
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
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"))
rows, total, err := a.store.ListPublic(limit, offset)
if err != nil {
writeErr(w, 500, "db error")
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
}
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
http.Error(w, "not found", 404)
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
http.Error(w, "paste expired", 404)
return
}
if row.PasswordHash.Valid {
http.Error(w, "password required", 401)
return
}
w.Header().Set("Content-Type", row.ContentType)
a.store.IncrementViews(row.ID)
w.Write([]byte(row.Content))
}
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
http.NotFound(w, r)
return
}
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)
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)
}
fmt.Fprintf(w, "</ul></body></html>")
}
func nullStrOr(ns sql.NullString, def string) string {
if ns.Valid {
return ns.String
}
return def
}
func (a *apiServer) handleHome(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("palette pastebin api\nPOST /api/pastes {\"content\": \"...\", \"language\": \"go\", \"expires_in\": \"168h\", \"password\": \"...\", \"visibility\": \"public\"}\nGET /api/pastes/{id}\nGET /api/public?limit=25&offset=0\nGET /raw/{id}\n"))
}
func (a *apiServer) handlePastePage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// if it looks like an asset request, 404
if strings.Contains(id, ".") {
http.NotFound(w, r)
return
}
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
http.NotFound(w, r)
return
}
a.store.IncrementViews(row.ID)
// render basic view; full templates come later with frontend work
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<!doctype html><html><head><title>%s — palette</title></head><body><pre>%s</pre></body></html>",
row.ID, templateEsc(row.Content))
}
func templateEsc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
func main() {
cfg := Config{
Addr: envOr("PALETTE_ADDR", ":8080"),
DBPath: envOr("PALETTE_DB", "palette.db"),
MaxTextBytes: int64(envIntOr("PALETTE_MAX_TEXT", 5*1024*1024)),
MaxItemBytes: int64(envIntOr("PALETTE_MAX_ITEM", 25*1024*1024)),
}
store, err := OpenStore(cfg.DBPath)
if err != nil {
log.Fatal(err)
}
store.StartSweeper(time.Minute)
ui, err := NewWebUI()
if err != nil {
log.Fatal(err)
}
webUIInstance = ui
srv := &apiServer{store: store, cfg: cfg}
log.Printf("palette listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, srv.routes()))
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func envIntOr(k string, d int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return d
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-183
View File
@@ -1,183 +0,0 @@
package main
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
//go:embed web/templates/*.html
var tmplFS embed.FS
//go:embed web/static
var staticFS embed.FS
type webUI struct {
tmpl *template.Template
}
func NewWebUI() (*webUI, error) {
funcs := template.FuncMap{
"humanSize": humanSize,
}
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "web/templates/*.html")
if err != nil {
return nil, err
}
return &webUI{tmpl: t}, nil
}
func humanSize(n int) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
if n < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(n)/1024)
}
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
func staticHandler() http.Handler {
sub, _ := fs.Sub(staticFS, "web/static")
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
}
func renderPage(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := webUIInstance.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "template error: "+err.Error(), 500)
}
}
var webUIInstance *webUI
func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "new.html", map[string]any{"Page": "new"})
}
func (a *apiServer) handleHistoryPage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "history.html", map[string]any{"Page": "history"})
}
func agoString(ts int64) string {
s := time.Now().Unix() - ts
switch {
case s < 60:
return fmt.Sprintf("%ds ago", s)
case s < 3600:
return fmt.Sprintf("%dm ago", s/60)
case s < 86400:
return fmt.Sprintf("%dh ago", s/3600)
default:
return fmt.Sprintf("%dd ago", s/86400)
}
}
func expiryString(expiresAt int64) string {
s := expiresAt - time.Now().Unix()
switch {
case s < 3600:
return fmt.Sprintf("%dm", s/60)
case s < 86400:
return fmt.Sprintf("%dh", s/3600)
default:
return fmt.Sprintf("%dd", s/86400)
}
}
func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string) {
lines := strings.Count(row.Content, "\n") + 1
gutter := ""
for i := 1; i <= lines; i++ {
gutter += fmt.Sprintf("%d\n", i)
}
expIn := ""
if row.ExpiresAt.Valid {
expIn = expiryString(row.ExpiresAt.Int64)
}
data := map[string]any{
"Page": "paste",
"ID": row.ID,
"Title": row.Title.String,
"Language": row.Language.String,
"ContentHTML": template.HTMLEscapeString(row.Content),
"ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines,
"SizeBytes": len(row.Content),
"CreatedAgo": agoString(row.CreatedAt),
"ViewCount": row.ViewCount,
"Visibility": row.Visibility,
"ExpiresAt": row.ExpiresAt.Valid,
"ExpiresIn": expIn,
"DeletionToken": deletionToken,
"JustCreated": justCreated,
"Host": "this host",
}
renderPage(w, "paste.html", data)
}
// handlePastePage renders the paste view; supports both ID and custom slug.
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil {
http.Error(w, "db error", 500)
return
}
if row == nil {
http.NotFound(w, r)
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
http.Error(w, "paste expired", 404)
return
}
if row.PasswordHash.Valid {
// if a password was submitted via unlock form, verify and set cookie for this paste
if r.Method == http.MethodPost {
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && checkPassword(row.PasswordHash.String, pw) {
http.SetCookie(w, &http.Cookie{
Name: "pw_" + row.ID, Value: "1", Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
// re-render without lock
a.renderPaste(w, row, false, "")
return
}
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt)})
return
}
// check cookie
c, err := r.Cookie("pw_" + row.ID)
if err != nil || c.Value != "1" {
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt)})
return
}
}
a.store.IncrementViews(row.ID)
justCreated := r.URL.Query().Get("created") == "1"
token := r.URL.Query().Get("token")
if justCreated && token != "" {
// one-time display of the deletion token via the created banner
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
// only pass the token to the template right after creation
if justCreated {
a.renderPaste(w, row, true, token)
return
}
a.renderPaste(w, row, false, "")
}
var _ = strconv.Itoa
-184
View File
@@ -1,184 +0,0 @@
/* Palette UI tokens (mirrors sketches/themes/tokens.css, midnight approved preset) */
:root {
--bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52;
--muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8;
--accent: #C4A8F0; --border: #42355C;
--radius: 14px;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace;
}
[data-preset="smooth"] {
--bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6;
--muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36;
--accent: #7A7796; --border: #DAD7E6;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--fg);
line-height: 1.45;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
font-size: 14px;
}
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 0 20px; height: 52px;
background: var(--surface); border-bottom: 1px solid var(--border);
}
.logo { font-weight: 700; font-size: 16px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); }
.logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; }
.topbar nav { display: flex; gap: 4px; }
.topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: 8px; font-size: 13.5px; }
.topbar nav a:hover { background: var(--surface-2); color: var(--fg); }
.topbar nav a.on { background: var(--accent); color: var(--bg); }
.topbar .spacer { flex: 1; }
.kbd { font-family: var(--font-mono); font-size: 11px; border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; color: var(--muted-fg); }
.float {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 2px 6px rgba(20,14,32,.25), 0 12px 32px rgba(20,14,32,.3);
overflow: hidden;
}
/* new paste page */
.deck {
display: grid; grid-template-columns: 1fr 300px; gap: 16px;
padding: 16px 20px; height: calc(100vh - 52px);
max-width: 1400px; margin: 0 auto;
}
.pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; }
.side-section { padding: 14px 16px; flex-shrink: 0; }
.side-section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
.pane-l { display: flex; flex-direction: column; }
.editor-head {
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.editor-head input {
border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13.5px; flex: 1;
}
.editor-head select {
border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg);
border-radius: 7px; padding: 4px 10px; font: inherit; font-size: 12.5px; cursor: pointer;
}
.editor-wrap { flex: 1; display: flex; min-height: 0; }
.gutter {
padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono);
font-size: 12.5px; line-height: 1.7; user-select: none; white-space: pre; overflow: hidden;
border-right: 1px solid var(--border);
}
.editor {
flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7;
white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg);
resize: none; width: 100%;
}
.editor::placeholder { color: var(--muted); }
.actionbar {
display: flex; align-items: center; gap: 14px; padding: 12px 16px;
border-top: 1px solid var(--border);
}
.btn {
background: var(--accent); color: var(--bg); border: none; cursor: pointer;
padding: 8px 18px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600;
}
.btn:hover { filter: brightness(1.08); }
.hint { font-size: 12px; color: var(--muted-fg); }
.hint b { color: var(--fg); font-weight: 550; }
.seg { display: flex; flex-direction: column; gap: 2px; }
.seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 7px; cursor: pointer; font-size: 13px; }
.seg label:hover { background: var(--surface-2); }
.seg input { accent-color: var(--accent); }
.toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; padding: 5px 8px; border-radius: 7px; }
.toggle:hover { background: var(--surface-2); }
.toggle input { accent-color: var(--accent); }
.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; padding: 4px 0; }
.row input[type="text"] {
border: 1px solid var(--border); border-radius: 7px; padding: 5px 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 12.5px; width: 130px;
}
.created-banner {
display: none; padding: 10px 16px; font-size: 13px; background: var(--surface-2);
border-bottom: 1px solid var(--border); word-break: break-all;
}
.created-banner a { color: var(--accent); }
/* paste view */
.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.meta-bar h1 { font-size: 17px; font-weight: 600; }
.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: 7px; }
.tag { font-size: 11.5px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; }
.meta-bar .spacer { flex: 1; }
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 8px; padding: 5px 12px; font: inherit; font-size: 12.5px; cursor: pointer; text-decoration: none; }
.iconbtn:hover { color: var(--fg); border-color: var(--muted); }
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
.code-head {
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
border-bottom: 1px solid var(--border); font-size: 12.5px; color: var(--muted-fg);
}
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.code {
font-family: var(--font-mono); font-size: 13px; line-height: 1.7;
padding: 14px 0; display: flex; overflow-x: auto;
}
.code .gutter { flex-shrink: 0; }
.codebody { padding: 0 18px; white-space: pre; }
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 12px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
/* history */
.page { max-width: 860px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.head-row { display: flex; align-items: baseline; gap: 14px; }
.head-row h1 { font-size: 20px; font-weight: 600; }
.search {
display: flex; align-items: center; gap: 8px; background: var(--surface);
border: 1px solid var(--border); border-radius: 10px; padding: 8px 14px; width: 260px;
}
.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13px; width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
th:nth-child(1), td:nth-child(1) { width: 130px; }
th:nth-child(2), td:nth-child(2) { width: auto; }
th:nth-child(3), td:nth-child(3) { width: 80px; }
th:nth-child(4), td:nth-child(4) { width: 80px; }
th:nth-child(5), td:nth-child(5) { width: 64px; }
th:nth-child(6), td:nth-child(6) { width: 90px; }
th {
text-align: left; font-size: 11px; 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); }
tr:last-child td { border-bottom: none; }
tr.row:hover td { background: var(--surface-2); }
td a.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); text-decoration: none; }
td a.slug:hover { color: var(--accent); }
.badge { font-size: 11px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: 999px; padding: 1px 8px; }
.badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; }
.pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 12.5px; color: var(--muted-fg); }
.pager .pg { display: flex; gap: 6px; }
.pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 7px; padding: 4px 11px; font: inherit; font-size: 12.5px; cursor: pointer; }
.pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); }
.pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.pager button:disabled { opacity: .4; cursor: default; }
.empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 13px; }
/* unlock */
.center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); }
.center .float { width: 400px; max-width: 100%; }
.inner { padding: 28px; text-align: center; }
.lockring {
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 24px;
}
.inner h1 { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
.inner .sub { font-size: 13px; color: var(--muted-fg); margin-bottom: 20px; }
.pwinput {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 9px;
background: var(--bg); color: var(--fg); font: inherit; font-size: 13.5px; outline: none; text-align: center;
letter-spacing: .12em;
}
.pwinput:focus { border-color: var(--accent); }
.center .btn { width: 100%; margin-top: 12px; }
.err { display: none; margin-top: 12px; font-size: 12.5px; color: #ff8fa3; }
.center .foot { font-size: 12px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
-1
View File
@@ -1 +0,0 @@
{{define "foot"}}{{end}}
-89
View File
@@ -1,89 +0,0 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Public pastes</h1>
<span class="count" id="count"></span>
<div class="spacer"></div>
<div class="search"><input id="filter" placeholder="filter…"></div>
</div>
<div class="float">
<table>
<thead><tr><th>Paste</th><th>Description</th><th>Language</th><th>Size</th><th>Views</th><th>Created</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
<div class="pager">
<span id="showing"></span>
<div class="pg" id="pg"></div>
</div>
</div>
</div>
<script>
const PER = 25;
let page = 1, total = 0;
const $ = id => document.getElementById(id);
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function fmtSize(n) { if (n == null) return '—'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; }
function ago(ts) {
const s = Math.floor(Date.now()/1000) - ts;
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
}
async function load() {
const off = (page - 1) * PER;
const res = await fetch('/api/public?limit=' + PER + '&offset=' + off);
const data = await res.json();
total = data.total;
$('count').textContent = total.toLocaleString() + ' total';
const rows = $('rows');
if (data.items.length === 0) {
rows.innerHTML = '';
$('empty').style.display = 'block';
} else {
$('empty').style.display = 'none';
rows.innerHTML = data.items.map(it =>
`<tr class="row"><td><a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a></td>` +
`<td class="title-cell">${it.title ? esc(it.title) : '<span class=dim>—</span>'}</td>` +
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
`<td class="dim">${fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim">${ago(it.created_at)}</td></tr>`
).join('');
}
const pages = Math.max(1, Math.ceil(total / PER));
$('showing').textContent = total === 0 ? 'Nothing here yet' :
`Showing ${off+1}${Math.min(off+PER, total)} of ${total.toLocaleString()} · page ${page} of ${pages}`;
const btns = [];
const add = (label, target, opts={}) => btns.push(`<button ${opts.on?'class="on"':''} ${opts.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', page-1, {dis: page===1});
const win = new Set([1, 2, page-1, page, page+1, pages]);
let last = 0;
for (let i = 1; i <= pages; i++) {
if (win.has(i)) {
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
add(String(i), i, {on: i===page});
last = i;
}
}
add('', page+1, {dis: page===pages});
$('pg').innerHTML = btns.join('');
}
$('pg').addEventListener('click', e => {
const b = e.target.closest('button[data-p]');
if (!b || b.disabled) return;
page = parseInt(b.dataset.p);
load();
window.scrollTo(0, 0);
});
$('filter').addEventListener('input', () => { page = 1; load(); });
load();
setInterval(load, 30000); // auto-refresh history every 30s
</script>
{{template "foot" .}}
-17
View File
@@ -1,17 +0,0 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
{{end}}
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</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}}>history</a>
<a href="https://git.archfox.org/poslop/palette">git</a>
</nav>
<div class="spacer"></div>
</div>
{{end}}
-104
View File
@@ -1,104 +0,0 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="deck">
<div class="float pane-l">
<div class="editor-head">
<input id="title" placeholder="title (optional)">
<select id="language">
<option value="">auto</option>
<option>go</option><option>python</option><option>javascript</option>
<option>rust</option><option>c</option><option>cpp</option><option>java</option>
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
<option>markdown</option><option>text</option>
</select>
</div>
<div class="editor-wrap">
<div class="gutter" id="gutter">1</div>
<textarea class="editor" id="content" placeholder="paste your code, text, or notes here…" spellcheck="false"></textarea>
</div>
<div class="created-banner" id="created"></div>
<div class="actionbar">
<span class="hint">Ctrl+Enter to create</span>
<div class="spacer" style="flex:1"></div>
<button class="btn" id="create">Create ⇧</button>
</div>
</div>
<div class="pane-r">
<div class="float side-section">
<h3>Expiry</h3>
<div class="seg">
<label><input type="radio" name="exp" value=""> Never</label>
<label><input type="radio" name="exp" value="1h"> 1 hour</label>
<label><input type="radio" name="exp" value="24h"> 1 day</label>
<label><input type="radio" name="exp" value="168h" checked> 1 week</label>
<label><input type="radio" name="exp" value="720h"> 30 days</label>
</div>
</div>
<div class="float side-section">
<h3>Protection</h3>
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<input type="password" id="password" class="pwinput" placeholder="password" style="display:none; margin: 6px 8px 0; width: auto;">
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<div class="row"><span>/</span><input type="text" id="custom" placeholder="my-snippet"></div>
</div>
<div class="float side-section">
<h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all"></div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
function updateGutter() {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
updateGutter();
$('haspw').addEventListener('change', e => { $('password').style.display = e.target.checked ? 'block' : 'none'; });
async function create() {
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('haspw').checked) body.password = $('password').value;
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp) body.expires_in = exp;
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
$('result').textContent = 'Error: ' + (data.error || res.status);
return;
}
const url = location.origin + '/' + (data.custom_slug || data.id);
$('result').innerHTML = '<a href="' + url + '">' + url + '</a>';
$('result').dataset.token = data.deletion_token || '';
try { navigator.clipboard.writeText(url); } catch(e) {}
// show the paste
location.href = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
}
$('create').addEventListener('click', create);
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
</script>
{{template "foot" .}}
-45
View File
@@ -1,45 +0,0 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="meta-bar">
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
<span class="slug">/{{.ID}}</span>
{{if .Language}}<span class="tag">{{.Language}}</span>{{end}}
{{if .ExpiresAt}}<span class="tag">expires in {{.ExpiresIn}}</span>{{end}}
<div class="spacer"></div>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" onclick="copyContent(); return false;">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
</div>
</div>
{{if .JustCreated}}
<div class="float">
<div class="created-banner" style="display:block">
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
</div>
</div>
{{end}}
<div class="float">
<div class="code-head"><span class="dot"></span> {{.LineCount}} lines · {{.SizeBytes}} bytes</div>
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
<div class="footnote">
<span>Created {{.CreatedAgo}}</span>
<span>{{.ViewCount}} views</span>
<span>{{.Visibility}}</span>
</div>
</div>
</div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script>
function copyContent() {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
}
function redeem(token) {
if (!confirm('Hard delete this paste immediately?')) return;
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
</script>
{{template "foot" .}}
-18
View File
@@ -1,18 +0,0 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float">
<div class="inner">
<div class="lockring">🔒</div>
<h1>This paste is locked</h1>
<p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p>
<form method="post" action="/unlock/{{.ID}}">
<input type="password" name="password" class="pwinput" placeholder="••••••••" autofocus>
{{if .Wrong}}<p class="err" style="display:block">Wrong password. Try again.</p>{{end}}
<button class="btn" type="submit">Unlock</button>
</form>
</div>
<div class="foot">Created {{.CreatedAgo}}</div>
</div>
</div>
{{template "foot" .}}