Author SHA1 Message Date
fen 1e696b1cbb docs: remove leftover "Create a can" curl example for deleted endpoint
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
2026-10-16 16:57:25 -05:00
fen e54fbc36ef 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.
2026-10-16 16:50:40 -05:00
fen 651bc0cb86 Add API documentation (docs/api.md) (#305)
CI / test (push) Successful in 20s
CI / docker (push) Successful in 44s
2026-10-16 20:46:45 +00:00
fen feb36fc501 Merge pull request '#296 fix attempt 3: shrink URL column, plain-text URL values' (#301) from fix-296-r3 into dev
CI / test (push) Successful in 26s
CI / docker (push) Successful in 41s
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-18 16:23:38 +00:00
fen ce14c6b713 #296 r3: shrink URL column, render URL values as plain text
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
Owner override postdating PR #300 QA pass:
- .col-url 340 -> 230px; reclaim slack from history-only columns (Views 96->90,
  Created 190->150, ID 190->120) so the table fits the .float pane with no
  horizontal scroll at 1400x900 and the ID column is fully visible.
- URL anchors drop the shared .slug pill (background/padding/radius/mono font)
  and render plain, styled via td.dim exactly like the none case; links stay
  anchors with accent hover. Ellipsis kept for genuinely long slugs.

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

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

Verified via CDP probe at 1400x900 and 375x812 on /history and /mine:
column order, colgroup/header alignment, sort arrow right of label, no
other column clipped, no CSP changes (no inline styles added).
2026-09-18 09:01:03 -05:00
fen a3abf7c73e Merge pull request '#296: move URL column to position 2 (after Paste) in list tables' (#297) from fix-296 into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 32s
2026-09-18 03:56:28 +00:00
fen 6e85a7686d #296: move URL column to position 2 (right after Paste) in history and mine tables
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-17 22:54:19 -05:00
fen 2046e45601 Merge pull request '#294: switching to midnight theme forces dark mode' (#295) from fix-294 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 34s
2026-09-18 03:14:20 +00:00
fen 105ffc9f17 #294: resolve midnight theme variant via lightPreset/darkPreset (midnight is dark-first)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-17 22:12:25 -05:00
fen 4fa60ea7b9 Merge pull request '#292: highlight paste name uniformly on row hover in list tables' (#293) from fix-292 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 35s
2026-09-18 02:58:13 +00:00
fen e87bdb221e #292: highlight paste name uniformly on row hover in list tables
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-17 21:45:44 -05:00
fen aa72b4b38b Merge pull request '#280: remove PALETTE_TRUSTED_IP_HEADER, rate limits key on peer address only' (#291) from fix-280-r2 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 42s
2026-09-18 01:53:23 +00:00
fen aac1725c25 #280: remove PALETTE_TRUSTED_IP_HEADER, key rate limits on peer address only
CI / test (pull_request) Successful in 31s
CI / docker (pull_request) Skipped
Owner follow-up to the #280 fix (PR #284): the trusted-header env var is
gone. clientIP() now uses the peer address exclusively and ignores all
client-supplied IP headers; the env var row is removed from the README.
2026-09-17 20:48:52 -05:00
24 changed files with 452 additions and 1363 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
Palette - self-hosted pastebin with paste cans Palette - self-hosted pastebin
Copyright (C) 2026 poslop Copyright (C) 2026 poslop
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
+2 -3
View File
@@ -10,7 +10,7 @@ a web UI for sharing text and small files.
## Features ## Features
- Text pastes and cans (multiple items in one share) - Text pastes
- File attachments (one file per paste, up to 25 MB) - File attachments (one file per paste, up to 25 MB)
- Password protected pastes - Password protected pastes
- Expire after a specified time - Expire after a specified time
@@ -66,11 +66,10 @@ go build -o palette ./cmd/palette
| `PALETTE_ADDR` | `:8080` | Listen address | | `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) | | `PALETTE_MAX_ITEM` | `26214400` | Max file attachment / multipart size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) | | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. | | `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. | | `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
| `PALETTE_TRUSTED_IP_HEADER` | unset | Name of a proxy-controlled client-IP header to key API rate limits on (e.g. `CF-Connecting-IP` when Cloudflare is the ingress; Cloudflare strips any client-supplied value). Unset: rate limits key on the peer address only, and all client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored. (#280) |
An `/admin` page exists for runtime settings, protected by a key set at An `/admin` page exists for runtime settings, protected by a key set at
install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See
+2 -2
View File
@@ -32,8 +32,8 @@ services:
# Default: 5242880 (5 MiB). # Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880" # PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can) or a # Max size in bytes for multipart request parsing (paste file
# paste file attachment. # attachments are capped at 25 MB).
# Default: 26214400 (25 MiB). # Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400" # PALETTE_MAX_ITEM: "26214400"
+256
View File
@@ -0,0 +1,256 @@
# 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 `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 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 with `413`.
- Attached files: 25 MB per file, one file per paste.
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
### Error format
Errors are JSON with a human-readable `error` message and, for create/validation failures, a machine-readable `code`:
```json
{"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:
1. `application/json` with 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 |
2. `multipart/form-data` with the same fields as form values plus a `file` part. 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`:
```json
{
"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:
```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",
"visibility": "public"
}'
```
Response:
```json
{
"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:
```bash
curl -X POST http://localhost:8080/api/pastes \
-F "title=quarterly report" \
-F "file=@report.pdf"
```
Fetch a paste:
```bash
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:
```bash
curl http://localhost:8080/raw/ab12cd34
```
Soft delete (paste becomes unavailable but is recoverable during the grace period):
```bash
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34
```
Hard delete immediately (row removed):
```bash
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34/redeem
```
## 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.
-273
View File
@@ -1,273 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"palette/internal/store"
"strings"
"time"
"github.com/go-chi/chi/v5"
"palette/internal/web"
)
// CreateCan makes a can with N items (multipart form).
// Fields: title, description, visibility, expires_in, password, files (one or more), or json_items for text items.
func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(a.cfg.MaxItemBytes); err != nil {
writeErr(w, 400, "multipart form required")
return
}
title := r.FormValue("title")
visibility := r.FormValue("visibility")
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
writeErrCode(w, 400, "invalid_visibility", "visibility must be public or unlisted")
return
}
expiresIn := r.FormValue("expires_in")
password := r.FormValue("password")
customSlug := r.FormValue("custom_slug")
var expiresAt *int64
now := time.Now().Unix()
if expiresIn != "" {
d, err := time.ParseDuration(expiresIn)
if err != nil {
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
return
}
// #60/#48: clamp at the API boundary like the pastes API does -
// reject zero/negative and durations past the 1-year UI cap.
if !store.ValidExpiry(d) {
writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year")
return
}
t := now + int64(d.Seconds())
expiresAt = &t
}
var pwHash *string
if password != "" {
h, err := store.Argon2IDHash(password)
if err != nil {
writeErr(w, 500, "hash error")
return
}
pwHash = &h
}
canID := store.GenSlug(8)
var slugPtr *string
if customSlug != "" {
slugPtr = &customSlug
}
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
if err != nil {
switch err {
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
writeErrCode(w, 409, createErrCode(err), err.Error())
default:
writeErr(w, 500, "db error")
}
return
}
if slugPtr != nil {
canID = customSlug // #4: custom slug becomes the can id
}
// #4: remember the creating browser so /mine and viewer-scoped delete work
a.store.Exec(`UPDATE paste_cans SET viewer_id=? WHERE id=?`, currentViewerID(r), canID)
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
itemCount := 0
if itemsJSON := r.FormValue("json_items"); itemsJSON != "" {
var items []map[string]string
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
writeErr(w, 400, "invalid json_items")
return
}
for _, it := range items {
content := it["content"]
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("item %q exceeds max", it["title"]))
return
}
lang := it["language"]
if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
// uploaded files
if r.MultipartForm != nil {
for _, headers := range r.MultipartForm.File {
for _, fh := range headers {
f, err := fh.Open()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
content, err := io.ReadAll(f)
f.Close()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("file %q exceeds max %d bytes", fh.Filename, a.cfg.MaxItemBytes))
return
}
contentStr := string(content)
if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
}
if itemCount == 0 {
a.store.DeleteCan(canID)
writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)")
return
}
writeJSON(w, 201, map[string]any{
"id": canID, "url": "/can/" + canID, "items": itemCount,
})
}
// handleDeleteCan soft-deletes a can (parity with paste deletion, #63):
// requires the vwr viewer cookie matching the can's viewer (cans carry no
// deletion token since they are built in the browser).
func (a *apiServer) handleDeleteCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
writeErr(w, 404, "can not found")
return
}
vid := currentViewerID(r)
if !(vid != "" && viewerSentCookie(r) && can.ViewerID.Valid &&
can.ViewerID.String != "" && can.ViewerID.String == vid) {
writeErr(w, 403, "deletion not authorized")
return
}
if _, err := a.store.SoftDeleteCan(can.ID); err != nil {
writeErr(w, 500, "db error")
return
}
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
func detectContentType(name string, content []byte) string {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".png"):
return "image/png"
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lower, ".gif"):
return "image/gif"
case strings.HasSuffix(lower, ".webp"):
return "image/webp"
case strings.HasSuffix(lower, ".pdf"):
return "application/pdf"
}
if len(content) > 8 && content[0] == 0x89 && content[1] == 'P' {
return "image/png"
}
return "text/plain"
}
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil {
writeErr(w, 500, "db error")
return
}
if can == nil {
writeErr(w, 404, "can not found")
return
}
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
writeErr(w, 404, "can expired")
return
}
if can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
items, err := a.store.ListCanItems(can.ID)
if err != nil {
writeErr(w, 500, "db error")
return
}
type itemMeta struct {
ID string `json:"id"`
Title *string `json:"title"`
ContentType string `json:"content_type"`
Size int `json:"size"`
URL string `json:"url"`
}
metas := make([]itemMeta, 0, len(items))
for _, it := range items {
metas = append(metas, itemMeta{
ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
})
}
writeJSON(w, 200, map[string]any{
"id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
"created_at": can.CreatedAt, "items": metas,
})
}
func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "item")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "item not found")
return
}
// must belong to a can
if !row.CanID.Valid {
writeErr(w, 404, "not a can item")
return
}
// inherit can password protection: password via header/query, or the
// same pw_<can> unlock cookie the can page sets (#4 cookie parity).
can, _ := a.store.GetCan(row.CanID.String)
if can != nil && can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
// fall back to the browser's unlock cookie for this can
c, cerr := r.Cookie("pw_" + can.ID)
if cerr != nil || c.Value != web.UnlockToken(can.ID) {
writeErr(w, 401, "password required")
return
}
}
}
// #34: same content-type guard as /raw — never serve active content types.
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))
}
-45
View File
@@ -1,45 +0,0 @@
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())
}
}
}
-414
View File
@@ -1,414 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"palette/internal/store"
)
// #4: cans appear in /api/public as normal rows with is_can=true.
func TestCanInPublicListing(t *testing.T) {
s := testServer(t)
h := s.routes()
// a can and a regular paste
body, ct := multipartBody(t, map[string]string{
"title": "Listed can",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"plain"}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("public: %d", rec.Code)
}
var got struct {
Total int `json:"total"`
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
Title any `json:"title"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Total != 2 {
t.Fatalf("expected total 2 (can + paste), got %d", got.Total)
}
foundCan := false
for _, it := range got.Items {
if it.ID == created.ID {
if !it.IsCan {
t.Fatalf("can row missing is_can flag")
}
foundCan = true
} else if it.IsCan {
t.Fatalf("plain paste flagged as can")
}
}
if !foundCan {
t.Fatalf("can not present in /api/public")
}
// #4: unlisted can must not be listed
body2, ct2 := multipartBody(t, map[string]string{
"title": "Hidden can",
"visibility": "unlisted",
"json_items": `[{"title":"b.txt","content":"BBB"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body2)
req.Header.Set("Content-Type", ct2)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("unlisted can create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Hidden can") {
t.Fatalf("unlisted can leaked into /api/public")
}
}
// #4: custom slug support for cans + conflict with existing paste slug.
func TestCanCustomSlug(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Slugged",
"custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can with slug: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID, URL string }
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID != "my-bundle" {
t.Fatalf("expected id my-bundle, got %q", created.ID)
}
// custom slug resolves on the can page route
req = httptest.NewRequest("GET", "/can/my-bundle", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Slugged") {
t.Fatalf("can page by slug: %d", rec.Code)
}
// duplicate slug rejected 409
body, ct = multipartBody(t, map[string]string{
"title": "Again", "custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("duplicate slug expected 409, got %d", rec.Code)
}
// reserved slug rejected
body, ct = multipartBody(t, map[string]string{
"title": "R", "custom_slug": "admin",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("reserved slug expected 409, got %d", rec.Code)
}
}
// #4: invalid expiry rejected on cans, mirroring paste behavior (#48).
func TestCanExpiryValidation(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Bad expiry", "expires_in": "30s",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400 for 30s expiry, got %d", rec.Code)
}
}
// #4: expired cans 404 on the page and API.
func TestCanExpiryAccess(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Dying", "expires_in": "1h",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// backdate expiry to force the expired path
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, created.ID)
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can API expected 404, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can page expected 404, got %d", rec.Code)
}
}
// #4: password-protected can page uses the same unlock flow as pastes.
func TestCanUnlockFlow(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Locked can", "password": "pw123",
"json_items": `[{"title":"secret.txt","content":"sec content"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// GET page without cookie -> unlock form
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "locked") {
t.Fatalf("expected unlock form, got %d", rec.Code)
}
// POST wrong password -> unlock form with error
fd := strings.NewReader("password=wrong")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "Wrong password") {
t.Fatalf("expected wrong-password message")
}
// POST correct password -> page renders, cookie set
fd = strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("unlocked can page missing item content")
}
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("unlock cookie not set")
}
// GET with cookie -> unlocked
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("cookie unlock failed: %d", rec.Code)
}
// a forged cookie value must not unlock
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("forged cookie unlocked the can")
}
}
// #4: can item access inherits the can unlock cookie (not just query param).
func TestCanItemCookieParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Cookie can", "password": "pw123",
"json_items": `[{"title":"s.txt","content":"sec"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// unlock via page to get the cookie
fd := strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("no unlock cookie")
}
// item id from API (with password query)
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
itemID := can.Items[0].ID
// API item with the unlock cookie but no password -> 200
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item via cookie expected 200, got %d", rec.Code)
}
// forged cookie -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("forged cookie on item expected 401, got %d", rec.Code)
}
}
// #4: viewer-scoped soft delete of cans.
func TestCanDeleteParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Doomed", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// delete without matching viewer -> 403
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 403 {
t.Fatalf("unauthorized delete expected 403, got %d", rec.Code)
}
// creator's browser -> 200
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("creator delete expected 200, got %d", rec.Code)
}
// gone from API and page
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("deleted can expected 404, got %d", rec.Code)
}
// gone from listings
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Doomed") {
t.Fatalf("deleted can still listed")
}
}
// #4: cans appear in /api/mine for the creating browser.
func TestCanInMine(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Mine can", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/mine", nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var got struct {
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if len(got.Items) != 1 || !got.Items[0].IsCan {
t.Fatalf("can missing from /api/mine: %s", rec.Body.String())
}
}
// #4: sweep soft-deletes expired cans (parity with pastes).
func TestCanSweepExpired(t *testing.T) {
s := testServer(t)
s.store.CreateCan("sweepcan", "sweep", "", "public", nil, 1, nil, nil)
s.store.InsertCanItem("sweepcan", "a.txt", "AAA", "text/plain", nil, nil, nil, 1)
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, "sweepcan")
s.store.SweepExpired()
if can, _ := s.store.GetCan("sweepcan"); can != nil {
t.Fatalf("expired can survived sweep")
}
_ = store.GenSlug(4) // keep import if store pkg shrinks
}
-144
View File
@@ -1,144 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
)
func multipartBody(t *testing.T, fields map[string]string, fileField, fileName, fileContent string) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
w.WriteField(k, v)
}
if fileField != "" {
fw, _ := w.CreateFormFile(fileField, fileName)
fw.Write([]byte(fileContent))
}
w.Close()
return &buf, w.FormDataContentType()
}
func TestCreateAndGetCan(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "My can",
"json_items": `[{"title":"a.txt","content":"AAA"},{"title":"b.txt","content":"BBB"}]`,
"expires_in": "1h",
}, "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 != 201 {
t.Fatalf("create can: got %d: %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
Items int `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.Items != 3 {
t.Fatalf("expected 3 items, got %d", created.Items)
}
// get can
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("get can: got %d", rec.Code)
}
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
if len(can.Items) != 3 {
t.Fatalf("expected 3 items in get, got %d", len(can.Items))
}
// fetch item
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+can.Items[0].ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item fetch: got %d", rec.Code)
}
}
func TestEmptyCanRejected(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{"title": "empty"}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400 for empty can, got %d", rec.Code)
}
}
func TestCanPasswordInheritedByItems(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "locked",
"password": "pw123",
"json_items": `[{"title":"s.txt","content":"sec"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// can without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("expected 401, got %d", rec.Code)
}
// get item id with pw
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
itemID := can.Items[0].ID
// item without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("item expected 401, got %d", rec.Code)
}
// item with pw -> 200
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item expected 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "sec") {
t.Fatalf("item content mismatch: %s", rec.Body.String())
}
}
+3 -33
View File
@@ -7,48 +7,18 @@
// per request and the limit was unenforceable (pentest H1: 6x201 across 8 // per request and the limit was unenforceable (pentest H1: 6x201 across 8
// rotating-XFF creates). // rotating-XFF creates).
// //
// Default: key on the actual peer address (RemoteAddr) only. Behind any // The bucket key is the actual peer address (RemoteAddr) only. Behind any
// reverse proxy this is the proxy's address, so all clients share one bucket // reverse proxy this is the proxy's address, so all clients share one bucket
// per endpoint — coarse, but safe. // per endpoint — coarse, but safe. Client-supplied IP headers
// // (X-Forwarded-For, X-Real-Ip) are never trusted.
// Proxy-honoring mode: a deployment in front of a proxy that OVERWRITES (not
// appends to) a client-IP header can set PALETTE_TRUSTED_IP_HEADER (e.g.
// CF-Connecting-IP when Cloudflare is the ingress; Cloudflare strips any
// client-supplied value). The header is honored ONLY when explicitly
// configured at startup, and X-Forwarded-For / X-Real-Ip are never trusted.
package api package api
import ( import (
"net" "net"
"net/http" "net/http"
"sync"
) )
var (
trustedIPMu sync.RWMutex
trustedIPHeader string // empty = never trust any client-IP header
)
// SetTrustedIPHeader configures the single proxy-controlled header whose
// value may key rate-limit buckets. Called at startup; tests may reset it.
func SetTrustedIPHeader(name string) {
trustedIPMu.Lock()
defer trustedIPMu.Unlock()
trustedIPHeader = name
}
func getTrustedIPHeader() string {
trustedIPMu.RLock()
defer trustedIPMu.RUnlock()
return trustedIPHeader
}
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
if name := getTrustedIPHeader(); name != "" {
if v := r.Header.Get(name); v != "" {
return v
}
}
host := r.RemoteAddr host := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
host = h host = h
+5 -2
View File
@@ -1,7 +1,7 @@
package api package api
// #143: create responses must set the short-lived tok_<id> HttpOnly cookie // #143: create responses must set the short-lived tok_<id> HttpOnly+Secure
// that the paste view reads for the one-time created banner. // cookie that the paste view reads for the one-time created banner.
import ( import (
"net/http" "net/http"
@@ -29,6 +29,9 @@ func TestCreateSetsDeletionTokenCookie(t *testing.T) {
if !c.HttpOnly { if !c.HttpOnly {
t.Error("tok_ cookie not HttpOnly") t.Error("tok_ cookie not HttpOnly")
} }
if !c.Secure {
t.Error("tok_ cookie not Secure")
}
if c.MaxAge != 60 { if c.MaxAge != 60 {
t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge) t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge)
} }
-35
View File
@@ -86,38 +86,3 @@ func TestErrorCodeRateLimited(t *testing.T) {
} }
t.Fatalf("never got rate_limited, last code = %q", code) t.Fatalf("never got rate_limited, last code = %q", code)
} }
func TestErrorCodeCanValidation(t *testing.T) {
h := testServer(t).routes()
post := func(fields map[string]string) *httptest.ResponseRecorder {
var b strings.Builder
for k, v := range fields {
b.WriteString("--B\r\nContent-Disposition: form-data; name=\"" + k + "\"\r\n\r\n" + v + "\r\n")
}
b.WriteString("--B--\r\n")
req := httptest.NewRequest("POST", "/api/pastes/can", strings.NewReader(b.String()))
req.Header.Set("Content-Type", "multipart/form-data; boundary=B")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// bad visibility
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "visibility": "nope"})); code != "invalid_visibility" {
t.Fatalf("visibility code = %q, want invalid_visibility", code)
}
// invalid expiry
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "expires_in": "1s"})); code != "expiry_invalid" {
t.Fatalf("expiry code = %q, want expiry_invalid", code)
}
// slug taken on can create
if rec := postCreate(h, `{"content":"a","custom_slug":"can-slug"}`); rec.Code != 201 {
t.Fatalf("seed create: %d", rec.Code)
}
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "custom_slug": "can-slug", "json_items": `[{"title":"a","content":"b"}]`})); code != "slug_taken" {
t.Fatalf("can slug code = %q, want slug_taken", code)
}
}
+6 -13
View File
@@ -7,8 +7,6 @@ import (
) )
func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
SetTrustedIPHeader("")
defer SetTrustedIPHeader("")
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "203.0.113.7:4432" r.RemoteAddr = "203.0.113.7:4432"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9") r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
@@ -18,18 +16,15 @@ func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
} }
} }
func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) { // A proxy-controlled header is not honored even when set: #280 revision
SetTrustedIPHeader("") // removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the
defer SetTrustedIPHeader("") // bucket key is the peer address only.
func TestClientIPNeverTrustsHeaders(t *testing.T) {
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "10.0.1.47:9999" r.RemoteAddr = "10.0.1.47:9999"
r.Header.Set("CF-Connecting-IP", "198.51.100.9") r.Header.Set("CF-Connecting-IP", "198.51.100.9")
if got := clientIP(r); got != "10.0.1.47" { if got := clientIP(r); got != "10.0.1.47" {
t.Fatalf("unconfigured: clientIP = %q, want peer 10.0.1.47", got) t.Fatalf("clientIP = %q, want peer 10.0.1.47", got)
}
SetTrustedIPHeader("CF-Connecting-IP")
if got := clientIP(r); got != "198.51.100.9" {
t.Fatalf("configured: clientIP = %q, want CF-Connecting-IP value", got)
} }
} }
@@ -37,9 +32,7 @@ func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) {
// repro was 8 creates with rotating XFF -> 6x201. // repro was 8 creates with rotating XFF -> 6x201.
func TestRotatingXFFDoesNotResetBucket(t *testing.T) { func TestRotatingXFFDoesNotResetBucket(t *testing.T) {
globalLimiter = newLimiter() globalLimiter = newLimiter()
defer SetTrustedIPHeader("") s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway
SetTrustedIPHeader("")
s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway
var allowed, limited int var allowed, limited int
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
+4 -146
View File
@@ -1,5 +1,5 @@
// Package api implements palette's REST handlers and the HTTP router: // Package api implements palette's REST handlers and the HTTP router:
// pastes, cans, guess-language, rate limiting middleware, and the admin API. // pastes, guess-language, rate limiting middleware, and the admin API.
package api package api
import ( import (
@@ -8,7 +8,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
@@ -18,7 +17,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
langpkg "palette/internal/lang"
"palette/internal/store" "palette/internal/store"
"palette/internal/web" "palette/internal/web"
) )
@@ -28,10 +26,6 @@ type Config struct {
DBPath string DBPath string
MaxTextBytes int64 MaxTextBytes int64
MaxItemBytes int64 MaxItemBytes int64
// TrustedIPHeader optionally names a proxy-controlled client-IP header
// (e.g. CF-Connecting-IP behind Cloudflare) to key rate limits on. Empty
// (default) keys on the peer address only. See clientip.go (#280).
TrustedIPHeader string
} }
type apiServer struct { type apiServer struct {
@@ -43,7 +37,6 @@ type apiServer struct {
} }
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer { func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
SetTrustedIPHeader(cfg.TrustedIPHeader) // #280
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey} return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
} }
@@ -64,8 +57,7 @@ func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": msg, "code": code}) writeJSON(w, status, map[string]string{"error": msg, "code": code})
} }
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error // createErrCode maps a store.CreatePaste error to its UI error code (#105).
// code (#105).
func createErrCode(err error) string { func createErrCode(err error) string {
switch { switch {
case errors.Is(err, store.ErrSlugTaken): case errors.Is(err, store.ErrSlugTaken):
@@ -108,16 +100,8 @@ func (a *apiServer) routes() http.Handler {
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion) r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic) r.Get("/public", a.handleListPublic)
r.Post("/guess-language", a.handleGuessLang) r.Post("/guess-language", a.handleGuessLang)
r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan)
r.Delete("/cans/{id}", a.handleDeleteCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
}) })
// can page (#4): GET renders, POST unlocks (same flow as pastes)
r.Get("/can/{id}", a.handleCanPage)
r.Post("/can/{id}", a.handleCanPage)
// raw + #38 attachment serving // raw + #38 attachment serving
r.Get("/f/{aid}/{filename}", a.handleServeAttachment) r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
r.Get("/raw/{id}", a.handleRaw) r.Get("/raw/{id}", a.handleRaw)
@@ -194,7 +178,7 @@ func viewerSentCookie(r *http.Request) bool {
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) { func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "tok_" + pasteID, Value: token, Path: "/", Name: "tok_" + pasteID, Value: token, Path: "/",
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
}) })
} }
@@ -447,7 +431,6 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename), "id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility, "custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
"is_can": row.IsCan,
}) })
} }
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -467,8 +450,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
items = append(items, map[string]any{ items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename), "id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
"is_can": row.IsCan,
}) })
} }
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -553,130 +535,6 @@ func safeRawContentType(ct string) bool {
return false 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
}
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
http.NotFound(w, r)
return
}
// #4: password-protected cans go through the same unlock flow as pastes:
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
// inherit the protection (handleCanItem checks the same cookie).
if can.PasswordHash.Valid {
h := a.webHandlers()
if r.Method == http.MethodPost {
if !rateLimitUnlock(can.ID, r) {
h.WriteRateLimited(w, 60)
return
}
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
http.SetCookie(w, &http.Cookie{
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
a.renderCan(w, can)
return
}
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": true,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
c, err := r.Cookie("pw_" + can.ID)
if err != nil || c.Value != web.UnlockToken(can.ID) {
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": false,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
}
a.renderCan(w, can)
}
// renderCan renders the can view page: title/description and items as cards.
// Text items expand inline; files link to download.
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
h := a.webHandlers()
items, err := a.store.ListCanItems(can.ID)
if err != nil {
http.Error(w, "db error", 500)
return
}
type canItem struct {
ID string
Title string
ContentType string
Size string
IsFile bool
Content string
ContentHTML template.HTML
Language string
}
cards := make([]canItem, 0, len(items))
totalSize := 0
for _, it := range items {
totalSize += len(it.Content)
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
ci := canItem{
ID: it.ID,
Title: nullStrOr(it.Title, it.ID),
ContentType: it.ContentType,
Size: web.HumanSize(len(it.Content)),
IsFile: isFile,
Language: it.Language.String,
}
if !isFile {
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
}
cards = append(cards, ci)
}
h.RenderPage(w, "can.html", map[string]any{
"Page": "can",
"ID": can.ID,
"Title": nullStrOr(can.Title, "Untitled can"),
"Description": can.Description.String,
"HasDescription": can.Description.Valid && can.Description.String != "",
"HasPassword": can.PasswordHash.Valid,
"Items": cards,
"ItemCount": len(cards),
"SizeHuman": web.HumanSize(totalSize),
"CreatedAgo": web.AgoString(can.CreatedAt),
"CreatedAtUnix": can.CreatedAt,
"ExpiresAt": can.ExpiresAt.Valid,
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
})
}
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
func expiryStringIfValid(ns sql.NullInt64) string {
if !ns.Valid {
return ""
}
remaining := ns.Int64 - time.Now().Unix()
s := remaining
switch {
case s < 3600:
return fmt.Sprintf("%dm", s/60)
case s < 86400:
return fmt.Sprintf("%dh", s/3600)
default:
return fmt.Sprintf("%dd", s/86400)
}
}
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) { func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
h := a.webHandlers() h := a.webHandlers()
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
) )
// #68 input-validation helpers. Paste/can payloads are size-capped and list // #68 input-validation helpers. Paste payloads are size-capped and list
// endpoints get a single place where limit/offset are parsed and clamped. // endpoints get a single place where limit/offset are parsed and clamped.
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the // maxRequestBody returns the HTTP body cap for JSON create endpoints: the
+1 -8
View File
@@ -10,7 +10,7 @@ var slugRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]{0,63}$`)
// reserved words that would collide with routes // reserved words that would collide with routes
var reservedSlugs = map[string]bool{ var reservedSlugs = map[string]bool{
"api": true, "raw": true, "can": true, "cans": true, "public": true, "api": true, "raw": true, "public": true,
"history": true, "static": true, "assets": true, "favicon.ico": true, "history": true, "static": true, "assets": true, "favicon.ico": true,
"new": true, "login": true, "logout": true, "admin": true, "settings": true, "new": true, "login": true, "logout": true, "admin": true, "settings": true,
"mine": true, "saved": true, "unlock": true, "guess": true, "f": true, "mine": true, "saved": true, "unlock": true, "guess": true, "f": true,
@@ -36,12 +36,5 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
if n > 0 {
return true, nil
}
err = s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans WHERE id = ?`, slug).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil return n > 0, nil
} }
+108
View File
@@ -0,0 +1,108 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
)
// Cans feature removal (2026-10-16): opening a store on a pre-removal
// database must drop the paste_cans table (only when empty) and the
// pastes.can_id column. A non-empty paste_cans table is left in place.
func TestMigrateDropsCansSchema(t *testing.T) {
dir := t.TempDir()
legacy := func(name string, canRows int) string {
db, err := sql.Open("sqlite", filepath.Join(dir, name))
if err != nil {
t.Fatalf("open raw db: %v", err)
}
defer db.Close()
stmts := []string{
`CREATE TABLE 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 TABLE 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
)`,
`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`,
`INSERT INTO pastes (id, content, content_type, visibility, can_id, created_at)
VALUES ('p1', 'x', 'text/plain', 'public', NULL, 1)`,
}
for _, q := range stmts {
if _, err := db.Exec(q); err != nil {
t.Fatalf("seed %q: %v", q, err)
}
}
for i := 0; i < canRows; i++ {
if _, err := db.Exec(`INSERT INTO paste_cans (id, created_at) VALUES (?, 1)`, string(rune('a'+i))); err != nil {
t.Fatalf("seed can row: %v", err)
}
}
return filepath.Join(dir, name)
}
intCol := func(s *Store, table, col string) int {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, col).Scan(&n)
return n
}
tableExists := func(s *Store, table string) bool {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n)
return n > 0
}
// Empty paste_cans: table and column both dropped.
s1, err := OpenStore(legacy("empty.db", 0))
if err != nil {
t.Fatalf("open: %v", err)
}
defer s1.db.Close()
if tableExists(s1, "paste_cans") {
t.Error("paste_cans still exists after migrate (was empty; should be dropped)")
}
if intCol(s1, "pastes", "can_id") != 0 {
t.Error("pastes.can_id still exists after migrate; should be dropped")
}
// The store still works end to end.
if _, err := s1.CreatePaste(&Paste{Content: "hello", Visibility: "public"}); err != nil {
t.Fatalf("create paste on migrated db: %v", err)
}
if got := s1.QueryInt(`SELECT COUNT(*) FROM pastes`); got != 2 {
t.Errorf("pastes count = %d, want 2", got)
}
// Non-empty paste_cans: table kept, can_id still dropped.
s2, err := OpenStore(legacy("full.db", 2))
if err != nil {
t.Fatalf("open: %v", err)
}
defer s2.db.Close()
if !tableExists(s2, "paste_cans") {
t.Error("non-empty paste_cans was dropped; must be kept until cleaned")
}
if intCol(s2, "pastes", "can_id") != 0 {
t.Error("pastes.can_id still exists after migrate; should be dropped")
}
// Fresh database: neither the table nor the column is ever created.
s3, err := OpenStore(filepath.Join(dir, "fresh.db"))
if err != nil {
t.Fatalf("open fresh: %v", err)
}
defer s3.db.Close()
if tableExists(s3, "paste_cans") {
t.Error("fresh db created paste_cans")
}
if intCol(s3, "pastes", "can_id") != 0 {
t.Error("fresh db created pastes.can_id")
}
}
+38 -146
View File
@@ -32,7 +32,6 @@ type Paste struct {
Visibility string `json:"visibility"` Visibility string `json:"visibility"`
// #83: accept "public": true/false as an alias for visibility. // #83: accept "public": true/false as an alias for visibility.
Public *bool `json:"public,omitempty"` Public *bool `json:"public,omitempty"`
CanID *string `json:"can_id,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"` DeletedAt *int64 `json:"deleted_at,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"`
@@ -55,29 +54,15 @@ type PasteRow struct {
ReadsLimit sql.NullInt64 ReadsLimit sql.NullInt64
ReadsUsed int ReadsUsed int
Visibility string Visibility string
CanID sql.NullString
CreatedAt int64 CreatedAt int64
DeletedAt sql.NullInt64 DeletedAt sql.NullInt64
ViewCount int ViewCount int
Size int Size int
DeletionToken sql.NullString DeletionToken sql.NullString
ViewerID sql.NullString ViewerID sql.NullString
IsCan bool // set on list rows that are cans (#4)
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
} }
type CanRow struct {
ID string
Title sql.NullString
Visibility string
PasswordHash sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64
Description sql.NullString
ViewerID sql.NullString
}
type Store struct { type Store struct {
db *sql.DB db *sql.DB
blob BlobStore // #38: attachment byte storage (nil in some unit tests) blob BlobStore // #38: attachment byte storage (nil in some unit tests)
@@ -119,7 +104,6 @@ func (s *Store) migrate() error {
expires_at INTEGER, expires_at INTEGER,
burn_after_read INTEGER DEFAULT 0, burn_after_read INTEGER DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'public', visibility TEXT NOT NULL DEFAULT 'public',
can_id TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
deleted_at INTEGER, deleted_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0, view_count INTEGER NOT NULL DEFAULT 0,
@@ -128,22 +112,32 @@ 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_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_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 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 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 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_limit INTEGER`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49) s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4) // Cans feature removal (owner decision 2026-10-16): drop the leftover
// schema. paste_cans is only dropped when empty so no data is silently
// destroyed; a non-empty table is left in place and logged. The dormant
// pastes.can_id column is dropped too (prod verified clean 2026-10-16:
// zero rows referenced a can).
var tbl int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='paste_cans'`).Scan(&tbl)
if tbl > 0 {
var rows int
s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans`).Scan(&rows)
if rows == 0 {
s.db.Exec(`DROP TABLE paste_cans`)
} else {
log.Printf("store: paste_cans still holds %d row(s); leaving the table in place (cans feature removed)", rows)
}
}
var col int
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pastes') WHERE name='can_id'`).Scan(&col)
if col > 0 {
s.db.Exec(`ALTER TABLE pastes DROP COLUMN can_id`)
}
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views ( s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL, paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL, viewer_id TEXT NOT NULL,
@@ -290,10 +284,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
} }
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) { 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) row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, 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) FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
var r PasteRow 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) err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -301,19 +295,15 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
} }
// ListPublic backs /api/public and the public listing page. Visibility rules // ListPublic backs /api/public and the public listing page. Visibility rules
// mirror the history page: only non-deleted, non-expired, non-can pastes are // mirror the history page: only non-deleted, non-expired pastes are listed,
// listed, and password-protected pastes are excluded at the query level // and password-protected pastes are excluded at the query level (#65) so
// (#65) so their metadata (title, slug, existence) never leaks. // their metadata (title, slug, existence) never leaks.
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '') rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
FROM pastes p FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?) WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -322,34 +312,27 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
for rows.Next() { for rows.Next() {
var r PasteRow var r PasteRow
var cs, lang, title sql.NullString var cs, lang, title sql.NullString
var isCan int if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &r.AttFilename); err != nil {
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
return nil, 0, err return nil, 0, err
} }
r.CustomSlug = cs r.CustomSlug = cs
r.Language = lang r.Language = lang
r.Title = title r.Title = title
r.IsCan = isCan == 1
out = append(out, r) out = append(out, r)
} }
var total int var total int
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)) s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`, time.Now().Unix()).Scan(&total)
time.Now().Unix(), time.Now().Unix()).Scan(&total)
return out, total, nil return out, total, nil
} }
// ListMine lists pastes created from the given viewer id (browser cookie), newest first. // ListMine lists pastes created from the given viewer id (browser cookie), newest first.
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) { func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '') rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
FROM pastes p FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND p.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?) WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -358,18 +341,15 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
for rows.Next() { for rows.Next() {
var r PasteRow var r PasteRow
var cs, lang, title sql.NullString var cs, lang, title sql.NullString
var isCan int if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &r.AttFilename); err != nil {
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
return nil, 0, err return nil, 0, err
} }
r.CustomSlug, r.Language, r.Title = cs, lang, title r.CustomSlug, r.Language, r.Title = cs, lang, title
r.IsCan = isCan == 1
out = append(out, r) out = append(out, r)
} }
var total int var total int
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)) s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
+ (SELECT COUNT(*) FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`, viewerID, time.Now().Unix()).Scan(&total)
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
return out, total, nil return out, total, nil
} }
@@ -432,8 +412,6 @@ func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool
func (s *Store) SweepExpired() { func (s *Store) SweepExpired() {
now := time.Now().Unix() 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) s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
// #4: cans expire too — mirror paste behavior
s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
grace := now - SoftDeleteGraceDays*86400 grace := now - SoftDeleteGraceDays*86400
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace) s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
} }
@@ -490,92 +468,6 @@ func (s *Store) HardDelete(id string) {
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id) s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
} }
// CreateCan inserts a paste_can row with optional custom slug (parity with
// pastes: validated by the same rules, checked against both tables).
// Returns ErrSlugTaken / ErrInvalidSlug / ErrReservedSlug on conflict.
func (s *Store) CreateCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64, customSlug *string) error {
if customSlug != nil && *customSlug != "" {
slug := *customSlug
if err := ValidateCustomSlug(slug); err != nil {
return err
}
taken, err := s.SlugTaken(slug)
if err != nil {
return err
}
if taken {
return ErrSlugTaken
}
canID = slug
}
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
return errors.New("visibility must be public or unlisted")
}
_, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
return err
}
// SoftDeleteCan marks a can deleted (its items stay; they are unlisted and
// hidden from listings by can_id and disappear with the can's page).
func (s *Store) SoftDeleteCan(canID string) (bool, error) {
res, err := s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), canID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// DeleteCan removes an (empty/aborted) can row.
func (s *Store) DeleteCan(canID string) {
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
}
// 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, description, viewer_id
FROM paste_cans WHERE (id = ?) AND deleted_at IS NULL`, id)
var c CanRow
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt, &c.Description, &c.ViewerID)
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. // GenSlug is the exported slug generator.
func GenSlug(n int) string { return genSlug(n) } func GenSlug(n int) string { return genSlug(n) }
+9 -25
View File
@@ -354,10 +354,15 @@ tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; } tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); } tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); } tr.row:hover td a.slug { color: var(--accent); }
/* #292: highlight the paste NAME uniformly on row hover, titled or not */
tr.row:hover td .paste-name { color: var(--accent); }
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; } td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.url-link { font-family: inherit; font-size: inherit; color: inherit; text-decoration: none; }
td a.url-link:hover { color: var(--accent); }
td a.slug:hover { color: var(--accent); } td a.slug:hover { color: var(--accent); }
/* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */ /* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */
td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); } td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); }
td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit; font-size: inherit; }
.badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; } .badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; }
.badge.lock { color: var(--accent); border-color: var(--accent); } .badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; } .dim { color: var(--muted-fg); white-space: nowrap; }
@@ -528,7 +533,7 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
/* paste name under slug pill in Paste column (#43) */ /* paste name under slug pill in Paste column (#43) */
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paste-sub.dim { color: var(--muted); } .paste-sub.dim { color: var(--muted); }
td a.slug.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; } td a.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; } td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); } td .id-link:hover { color: var(--accent); }
@@ -689,26 +694,6 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
.center .foot { font-size: 13px; } .center .foot { font-size: 13px; }
} }
/* #4: can view page + can builder */
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.can-item { padding: 12px 14px; }
.can-item-head { display: flex; align-items: center; gap: 10px; }
.can-item-head strong { flex: 1; word-break: break-all; }
.can-item-body { margin-top: 8px; }
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
/* #169: divider between the dropdown title and body must be persistent
(not hover dependent) and extend the full width of the card left and right */
.can-item-body[open] summary {
border-bottom: 1px solid var(--border);
margin: 0 -14px 8px;
padding: 0 14px 8px;
}
.can-item-body pre.code { margin: 8px 0 0; overflow-x: auto; }
.can-item-row { margin-top: 8px; }
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
/* ============================================================ /* ============================================================
Styled controls (#123) — buttons, selects, checkboxes, radios Styled controls (#123) — buttons, selects, checkboxes, radios
match the custom URL box house pattern: 1px var(--border) on match the custom URL box house pattern: 1px var(--border) on
@@ -887,13 +872,12 @@ button[type="submit"]:focus-visible,
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */ /* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
.hidden { display: none; } .hidden { display: none; }
.can-page { max-width: 900px; width: 100%; }
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } .col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
.col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; } .col-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
.col-f { width: 100px; } .col-g { width: 190px; } .col-f { width: 100px; } .col-g { width: 120px; }
/* #255: history's URL column had its own narrow width (col-f doubles as /* #255: history's URL column had its own narrow width (col-f doubles as
/mine's ID column); give it a dedicated class. */ /mine's ID column); give it a dedicated class. */
.col-url { width: 150px; } .col-url { width: 230px; }
/* #210: /mine rows render a delete button cell that had no declared column, /* #210: /mine rows render a delete button cell that had no declared column,
so under table-layout:fixed it overlapped the ID column. */ so under table-layout:fixed it overlapped the ID column. */
.col-del { width: 64px; } .col-del { width: 64px; }
+3 -3
View File
@@ -6,12 +6,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` + `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`, `<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.', emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.', emptyAll: 'No pastes yet. Create the first one.',
+3 -3
View File
@@ -18,12 +18,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` + `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` + `<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`, `<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.', emptyFiltered: 'No pastes from this browser match your search.',
+4 -1
View File
@@ -73,7 +73,10 @@
}); });
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var dark = state().dark; var dark = state().dark;
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id; // #294: use the generic variant resolvers - midnight is dark-first
// (dark preset = 'midnight', light = 'midnight-light'), so a raw
// t.id + '-dark' build landed on nonexistent/forced-dark presets.
document.documentElement.dataset.preset = dark ? darkPreset(t.id) : lightPreset(t.id);
try { localStorage.setItem('palette-theme', t.id); } catch (e) {} try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true'); btn.setAttribute('aria-pressed', 'true');
-36
View File
@@ -1,36 +0,0 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float can-page">
<div class="inner">
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
<p class="hint">{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}</p>
<div class="can-items">
{{range .Items}}
<div class="can-item float">
<div class="can-item-head">
<strong>{{.Title}}</strong>
<span class="dim">{{.Size}}</span>
{{if .IsFile}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" download="{{.Title}}" title="Download file" aria-label="Download {{.Title}}">↓</a>
{{else}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" title="Raw item" aria-label="Raw {{.Title}}">⧉</a>
{{end}}
</div>
{{if not .IsFile}}
<details class="can-item-body">
<summary>Show content</summary>
<pre class="code"><code>{{.ContentHTML}}</code></pre>
</details>
{{else}}
<div class="hint">Attached file — use the download button above.</div>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
{{template "foot" .}}
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div> <div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <table>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-e"><col class="col-f"><col class="col-del"></colgroup> <colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-url"><col class="col-f"><col class="col-del"></colgroup>
<thead><tr> <thead><tr>
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th> <th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
<th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th> <th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th>
+5 -28
View File
@@ -1,5 +1,5 @@
// Package web serves palette's HTML routes: paste pages, cans, unlock, and // Package web serves palette's HTML routes: paste pages, unlock, and the
// the admin page. Templates and static assets are embedded in this package. // admin page. Templates and static assets are embedded in this package.
package web package web
import ( import (
@@ -111,29 +111,6 @@ func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
h.renderNotFound(w, r, r.URL.Path) h.renderNotFound(w, r, r.URL.Path)
} }
// RenderPage is the exported wrapper used by the api package (#4 can pages).
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
h.renderPage(w, name, data)
}
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
h.writeRateLimited(w, retryAfterSecs)
}
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
// Exported for the api package so can pages share paste cookie semantics (#4).
func UnlockToken(id string) string { return unlockToken(id) }
// AgoString formats a relative "N ago" string (exported for the api package, #4).
func AgoString(ts int64) string { return agoString(ts) }
// HumanSize formats a byte count as a human string (exported for the api package, #4).
func HumanSize(n int) string { return humanSize(n) }
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
// #34: per-paste unlock tokens. unlockSecret is generated once at startup // #34: per-paste unlock tokens. unlockSecret is generated once at startup
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and // (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 // used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
@@ -369,7 +346,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
// the paste id under the server's random secret. // the paste id under the server's random secret.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/", Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
}) })
// re-render without lock, or redirect if ?next= was given (#26) // re-render without lock, or redirect if ?next= was given (#26)
if next := r.FormValue("next"); next != "" { if next := r.FormValue("next"); next != "" {
@@ -403,7 +380,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
} }
if justCreated && token != "" { if justCreated && token != "" {
// one-time display of the deletion token via sessionStorage (#143) // one-time display of the deletion token via sessionStorage (#143)
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode}) http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true})
} }
// Count the view for real page renders, deduped per-viewer within the // Count the view for real page renders, deduped per-viewer within the
// burn window (#95): a reload by the same vwr cookie doesn't inflate // burn window (#95): a reload by the same vwr cookie doesn't inflate
@@ -466,7 +443,7 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} } func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
// #59: security headers for rendered HTML pages. Applied wherever the // #59: security headers for rendered HTML pages. Applied wherever the
// response is text/html (page templates and the inline can page); JSON API // response is text/html (page templates); JSON API
// responses and /raw content pass through untouched. // responses and /raw content pass through untouched.
// #139: script-src and style-src no longer allow 'unsafe-inline'. All // #139: script-src and style-src no longer allow 'unsafe-inline'. All
// previously-inline scripts moved to external files under static/ (page data // previously-inline scripts moved to external files under static/ (page data