Owner decision 2026-10-16: the cans feature (bundles of pastes, #4) was half-integrated and has zero prod usage; removing it eliminates four audit findings by deletion. Removed: - internal/api/cans.go and all can routes (/api/pastes/can, /api/cans/*, /can/{id}) plus handleCanPage/renderCan - is_can from /api/mine and /api/public responses - store layer: CanRow, can store funcs, paste_cans UNION in listings, cans sweep; reserved slugs 'can'/'cans' released - templates/can.html, can CSS, is_can badges in history.js/mine.js - cans tests (cans_test.go, cans_flow_test.go, cans_expiry_test.go, TestErrorCodeCanValidation) - docs/api.md cans section, README/LICENSE/docker-compose mentions Store migration: drops paste_cans only when empty (non-empty table is kept and logged; prod verified clean 2026-10-16) and drops the dormant pastes.can_id column. Covered by new TestMigrateDropsCansSchema. Security hardening (audit findings): pw_<id> (unlock) and tok_<id> (deletion-token) cookies now carry Secure: true in addition to HttpOnly + SameSite=Lax; all deployments are HTTPS-only. cookie_set_test now asserts Secure. go vet ./... and go test ./... pass.
11 KiB
Palette API
Palette's REST API lives under /api on the same port that serves the web UI. All request and response bodies are JSON unless noted otherwise. This document describes the API as implemented in internal/api.
Overview
Base URL
The server listens on the address set by PALETTE_ADDR (default :8080). In production palette runs behind a reverse proxy, so API URLs are relative to the site root, for example:
https://paste.example.com/api/pastes
The examples below use http://localhost:8080 as a placeholder.
Authentication
There is no user account system. Access control works through three mechanisms:
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly
vwrcookie. This cookie identifies the browser that created a paste, and authorizes browser-scoped actions such asGET /api/mineand creator-only deletion. - Deletion token. Creating a paste returns a one-time
deletion_token. Sending it in theAuthorizationheader (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history. - Admin key. The admin settings endpoints require an install-time key sent in the
X-Admin-Keyheader. The key comes fromPALETTE_ADMIN_KEYor is generated and persisted server-side at first start.
Password-protected pastes are unlocked with the X-Paste-Password request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
Rate limiting
Rate limits are token buckets keyed by client IP (the rightmost X-Forwarded-For entry, then X-Real-Ip, then the remote address). Current limits:
| Scope | Limit | Refill |
|---|---|---|
| Paste create | 5 burst (admin tunable) | 60 per minute (admin tunable) |
| Language guess | 5 burst | 1 per second |
| Password unlock | 5 burst, per IP and paste | 5 per minute |
| Admin key check | 5 burst, per IP | 5 per minute |
Exceeding a limit returns 429 with a Retry-After header and the error code rate_limited. Create and guess responses include X-RateLimit-Limit and X-RateLimit-Burst headers.
Size limits
- Paste content:
PALETTE_MAX_TEXT(default 5 MB), admin tunable. Larger content is rejected with413. - Attached files: 25 MB per file, one file per paste.
- The request body itself is capped server side; oversized bodies fail with
413before handlers run.
Error format
Errors are JSON with a human-readable error message and, for create/validation failures, a machine-readable code:
{"error": "slug already taken", "code": "slug_taken"}
Known codes: content_empty, content_too_large, slug_invalid, slug_taken, slug_reserved, expiry_invalid, invalid_visibility, one_file_only, file_too_large, rate_limited, invalid_request. Unknown codes should be treated as a generic failure.
Endpoint reference
Pastes
POST /api/pastes
Create a paste. Rate limited.
Two body formats:
application/jsonwith these fields:
| Field | Type | Notes |
|---|---|---|
content |
string | required; non-whitespace; up to the content cap |
title |
string | optional, trimmed, truncated to 200 chars |
language |
string | optional, max 40 chars, ^[a-zA-Z0-9+#-]{1,40}$ |
custom_slug |
string | optional custom URL slug |
password |
string | optional; hash stored server side |
expires_in |
string | Go duration (90m, 6h, 168h); 1 minute to 1 year |
burn_after_read |
bool | convenience for a single-read budget |
burn_after_reads |
int | number of reads the paste survives (min 1) |
visibility |
string | public or unlisted; default public |
public |
bool | alias for visibility; takes precedence when both are sent |
multipart/form-datawith the same fields as form values plus afilepart. A paste is either text or one file; if both are sent the file wins and the text is ignored. The MIME type is sniffed from the file content, never from client headers.
If the admin has set a default expiry, pastes created without expires_in inherit it.
Response 201:
{
"id": "ab12cd34",
"deletion_token": "one-time token",
"url": "/ab12cd34",
"raw_url": "/raw/ab12cd34",
"api_url": "/api/pastes/ab12cd34",
"expires_at": 1735689600,
"created_at": 1735603200,
"rate_limit": {"create_per_sec": 1, "burst": 5}
}
With a multipart file, an extra attachment object is returned: id, filename, mime, size, sha256, url (/f/{aid}/{filename}).
The deletion token is also set as a short-lived tok_{id} cookie for the creating browser.
Errors: 400 invalid JSON or validation (codes content_empty, slug_invalid, slug_taken, slug_reserved, expiry_invalid, one_file_only), 413 (content_too_large, file_too_large), 429 rate limited.
GET /api/pastes/{id}
Fetch a paste's JSON representation. For password-protected pastes send X-Paste-Password; wrong or missing passwords return 401 and count against the unlock rate limit.
Response 200: id, content, content_type, language, title, created_at, view_count, visibility, and reads_remaining (number or null when no read budget is set).
Errors: 404 not found, expired, or burned out; 401 password required; 429 too many unlock attempts.
DELETE /api/pastes/{id}
Soft delete a paste. Requires either the deletion token in the Authorization header or the creating browser's vwr cookie.
Response 200: {"status": "soft-deleted"}.
Errors: 403 deletion token required, 404 not found.
DELETE /api/pastes/{id}/redeem
Hard delete immediately using the one-time deletion token in the Authorization header. The paste row is removed entirely instead of waiting for the sweeper.
Response 200: {"status": "deleted"}.
Errors: 400 token required, 403 invalid token, 404 not found.
GET /api/mine
List pastes created from this browser, via the vwr cookie. Query parameters: limit (default 50, max 100), offset.
Response 200: {"total": n, "limit": l, "offset": o, "items": [...]} with each item carrying id, title, language, created_at, view_count, size, custom_slug, visibility.
GET /api/public
List public pastes. Query parameters: limit (default 25, max 100), offset. Same shape as /api/mine; items include custom_slug.
Files and raw content
GET /raw/{id}
Paste content served verbatim. Content types outside a safe fixed set (plain text, markdown, JSON, PDF, common images, octet-stream) are served as text/plain with nosniff. Raw reads count against a burn-after-read budget, with the same per-viewer dedupe window as page views (15 minutes by default, admin tunable). Password-protected pastes are not available raw (401).
Errors: 401 password required, 404 not found, expired, or burned.
GET /f/{aid}/{filename}
Download a file attachment. The filename path segment is decorative; lookups key on the attachment id. Files are served with the server-sniffed MIME type, nosniff, and a safe Content-Disposition; only images and PDFs render inline, everything else downloads. Attachments inherit the paste lifecycle: expired or burned pastes make their attachments disappear.
Errors: 404 attachment or paste not found.
Language detection
POST /api/guess-language
Body: {"content": "package main"}. Rate limited like create.
Response 200: {"language": "go"}. The result is a best-effort guess and may be "".
Errors: 400 invalid JSON body, 429 rate limited.
Admin
GET /admin/api/settings and POST /admin/api/settings
Read or update runtime settings. Requires the X-Admin-Key header; wrong keys return 401 and all admin key checks are rate limited to 5 per minute per IP. The admin key is never accepted as a query parameter.
Settings object (POST accepts the full object and persists it):
| Field | Default | Meaning |
|---|---|---|
rate_limit_burst |
5 | paste-create token bucket burst |
rate_limit_per_minute |
60 | paste-create refill rate |
max_content_bytes |
5242880 | paste content cap |
default_expiry |
"" |
expiry applied to pastes created without one |
custom_slug_reservation_days |
30 | how long a released slug stays reserved |
burn_viewer_window_minutes |
15 | per-viewer dedupe window for burn reads |
Errors: 400 invalid JSON or validation (all numeric settings must be positive, default_expiry must be a duration between 1 minute and 1 year or empty), 401 unauthorized, 429 rate limited.
Examples
Create a text paste:
curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \
-d '{
"content": "print(hello)",
"title": "my snippet",
"language": "python",
"expires_in": "168h",
"visibility": "public"
}'
Response:
{
"id": "ab12cd34",
"deletion_token": "TOKEN",
"url": "/ab12cd34",
"raw_url": "/raw/ab12cd34",
"api_url": "/api/pastes/ab12cd34",
"expires_at": 1735689600,
"created_at": 1735603200,
"rate_limit": {"create_per_sec": 1, "burst": 5}
}
Save the deletion_token; it is shown once and authorizes deletion.
Create a file paste:
curl -X POST http://localhost:8080/api/pastes \
-F "title=quarterly report" \
-F "file=@report.pdf"
Fetch a paste:
curl http://localhost:8080/api/pastes/ab12cd34
# password-protected paste (header only; query parameters are not accepted):
curl -H "X-Paste-Password: secret" http://localhost:8080/api/pastes/ab12cd34
Fetch raw content:
curl http://localhost:8080/raw/ab12cd34
Soft delete (paste becomes unavailable but is recoverable during the grace period):
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34
Hard delete immediately (row removed):
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34/redeem
Create a can:
curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \
-F "expires_in=48h" \
-F "custom_slug=my-bundle" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png"
Deletion and expiry behavior
- Expired pastes are soft-deleted by a background sweeper that runs every minute.
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- The deletion token allows immediate hard delete via the redeem endpoint.
- Burn-after-read pastes are soft-deleted once the read budget is exhausted. A read is counted once per unique viewer within the dedupe window (15 minutes by default); the same viewer returning inside the window does not consume another read.