Remove cans feature; add Secure flag to pw_/tok_ cookies
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped

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.
This commit is contained in:
fen committed 2026-10-16 16:50:40 -05:00
1 parent 651bc0cb86
commit e54fbc36ef
20 files changed
+176 -1356

No files matched your search

+5 -53
View File
@@ -18,11 +18,11 @@ The examples below use `http://localhost:8080` as a placeholder.
There is no user account system. Access control works through three mechanisms:
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste or can, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
- Deletion token. Creating a paste returns a one-time `deletion_token`. Sending it in the `Authorization` header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
- Admin key. The admin settings endpoints require an install-time key sent in the `X-Admin-Key` header. The key comes from `PALETTE_ADMIN_KEY` or is generated and persisted server-side at first start.
Password-protected pastes and cans 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.
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
@@ -41,7 +41,6 @@ Exceeding a limit returns `429` with a `Retry-After` header and the error code `
- Paste content: `PALETTE_MAX_TEXT` (default 5 MB), admin tunable. Larger content is rejected with `413`.
- Attached files: 25 MB per file, one file per paste.
- Can items: `PALETTE_MAX_ITEM` (default 25 MB) per item.
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
### Error format
@@ -130,13 +129,13 @@ Errors: `400` token required, `403` invalid token, `404` not found.
#### GET /api/mine
List pastes (and cans) created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
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`, `is_can`.
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 `is_can` and `custom_slug`.
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `custom_slug`.
### Files and raw content
@@ -162,53 +161,6 @@ Response `200`: `{"language": "go"}`. The result is a best-effort guess and may
Errors: `400` invalid JSON body, `429` rate limited.
### Cans
A can bundles multiple text items and files into one shareable page at `/can/{id}`. Cans appear in `/api/mine` and `/api/public` with `is_can: true`.
#### POST /api/pastes/can
Create a can. Multipart form only. Fields:
| Field | Type | Notes |
|---|---|---|
| `title` | string | optional |
| `description` | string | optional |
| `visibility` | string | `public` or `unlisted`; default `public` |
| `expires_in` | string | Go duration, 1 minute to 1 year |
| `password` | string | optional |
| `custom_slug` | string | optional; becomes the can id |
| `json_items` | string | JSON array of `{"title": "...", "content": "...", "language": "..."}` text items |
| files | parts | one or more uploaded file parts |
A can needs at least one item (file or `json_items`); otherwise the can is deleted and `400` (`content_empty`) is returned.
Response `201`: `{"id": "...", "url": "/can/{id}", "items": n}`.
Errors: `400` (`invalid_visibility`, `expiry_invalid`, `invalid json_items`, `content_empty`), `409` slug errors (codes `slug_taken`, `slug_invalid`, `slug_reserved`), `413` item over the size cap.
#### GET /api/cans/{id}
Can metadata with an item list. Password-protected cans require `X-Paste-Password`.
Response `200`: `id`, `title`, `visibility`, `created_at`, and `items`, where each item has `id`, `title`, `content_type`, `size`, and `url` (the item's fetch URL).
Errors: `404` not found or expired, `401` password required.
#### GET /api/cans/{id}/items/{item}
Fetch one can item's content. Items inherit the can's password: send `X-Paste-Password`, or the browser's `pw_{can}` unlock cookie. Served with the same safe content-type guard as `/raw`.
Errors: `404` item not found or not a can item, `401` password required.
#### DELETE /api/cans/{id}
Soft delete a can. Creator browser only: the `vwr` cookie must match the can's viewer. Cans carry no deletion token.
Response `200`: `{"status": "soft-deleted"}`.
Errors: `403` deletion not authorized, `404` not found.
### Admin
#### GET /admin/api/settings and POST /admin/api/settings