Author SHA1 Message Date
fen cb1b16ae41 Merge pull request 'Release v0.5.0: dev -> main' (#288) from dev into main
CI / test (push) Successful in 26s
CI / docker (push) Successful in 46s
2026-09-18 01:49:23 +00:00
13 changed files with 65 additions and 394 deletions

No files matched your search

+1
View File
@@ -70,6 +70,7 @@ go build -o palette ./cmd/palette
| `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
-315
View File
@@ -1,315 +0,0 @@
# 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 or can, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
- Deletion token. Creating a paste returns a one-time `deletion_token`. Sending it in the `Authorization` header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
- Admin key. The admin settings endpoints require an install-time key sent in the `X-Admin-Key` header. The key comes from `PALETTE_ADMIN_KEY` or is generated and persisted server-side at first start.
Password-protected pastes and cans are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
### 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.
- Can items: `PALETTE_MAX_ITEM` (default 25 MB) per item.
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
### Error format
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 (and cans) created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`, `is_can`.
#### GET /api/public
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `is_can` and `custom_slug`.
### 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.
### Cans
A can bundles multiple text items and files into one shareable page at `/can/{id}`. Cans appear in `/api/mine` and `/api/public` with `is_can: true`.
#### POST /api/pastes/can
Create a can. Multipart form only. Fields:
| Field | Type | Notes |
|---|---|---|
| `title` | string | optional |
| `description` | string | optional |
| `visibility` | string | `public` or `unlisted`; default `public` |
| `expires_in` | string | Go duration, 1 minute to 1 year |
| `password` | string | optional |
| `custom_slug` | string | optional; becomes the can id |
| `json_items` | string | JSON array of `{"title": "...", "content": "...", "language": "..."}` text items |
| files | parts | one or more uploaded file parts |
A can needs at least one item (file or `json_items`); otherwise the can is deleted and `400` (`content_empty`) is returned.
Response `201`: `{"id": "...", "url": "/can/{id}", "items": n}`.
Errors: `400` (`invalid_visibility`, `expiry_invalid`, `invalid json_items`, `content_empty`), `409` slug errors (codes `slug_taken`, `slug_invalid`, `slug_reserved`), `413` item over the size cap.
#### GET /api/cans/{id}
Can metadata with an item list. Password-protected cans require `X-Paste-Password`.
Response `200`: `id`, `title`, `visibility`, `created_at`, and `items`, where each item has `id`, `title`, `content_type`, `size`, and `url` (the item's fetch URL).
Errors: `404` not found or expired, `401` password required.
#### GET /api/cans/{id}/items/{item}
Fetch one can item's content. Items inherit the can's password: send `X-Paste-Password`, or the browser's `pw_{can}` unlock cookie. Served with the same safe content-type guard as `/raw`.
Errors: `404` item not found or not a can item, `401` password required.
#### DELETE /api/cans/{id}
Soft delete a can. Creator browser only: the `vwr` cookie must match the can's viewer. Cans carry no deletion token.
Response `200`: `{"status": "soft-deleted"}`.
Errors: `403` deletion not authorized, `404` not found.
### Admin
#### GET /admin/api/settings and POST /admin/api/settings
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
```
Create a can:
```bash
curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \
-F "expires_in=48h" \
-F "custom_slug=my-bundle" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png"
```
## Deletion and expiry behavior
- Expired pastes are soft-deleted by a background sweeper that runs every minute.
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- The deletion token allows immediate hard delete via the redeem endpoint.
- Burn-after-read pastes are soft-deleted once the read budget is exhausted. A read is counted once per unique viewer within the dedupe window (15 minutes by default); the same viewer returning inside the window does not consume another read.
+2 -17
View File
@@ -186,24 +186,9 @@ func (a *apiServer) handleCreatePasteMultipart(w http.ResponseWriter, r *http.Re
case "burn_after_read": case "burn_after_read":
p.BurnAfterRead = v == "true" || v == "1" || v == "on" p.BurnAfterRead = v == "true" || v == "1" || v == "on"
case "burn_after_reads": case "burn_after_reads":
if v == "" { // absent value: unset, like the JSON path's omitempty if n, err := strconv.Atoi(v); err == nil {
continue p.BurnAfterReads = &n
} }
n, err := strconv.Atoi(v)
if err != nil {
// malformed burn_after_reads is a 400, not a silently-dropped
// budget: same 400-with-code shape the JSON path uses for
// invalid values (which fail unmarshal into the int field).
writeErrCode(w, 400, "burn_invalid", "burn_after_reads must be a positive number")
return
}
// negative values are invalid too: same 400 burn_invalid shape
// as the JSON path (validate.go:92), identical message text.
if err := parseBurnAfterReads(n); err != nil {
writeErrCode(w, 400, "burn_invalid", err.Error())
return
}
p.BurnAfterReads = &n
} }
} }
+33 -3
View File
@@ -7,18 +7,48 @@
// 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).
// //
// The bucket key is the actual peer address (RemoteAddr) only. Behind any // Default: key on 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. Client-supplied IP headers // per endpoint — coarse, but safe.
// (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
+13 -6
View File
@@ -7,6 +7,8 @@ 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")
@@ -16,15 +18,18 @@ func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
} }
} }
// A proxy-controlled header is not honored even when set: #280 revision func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) {
// removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the SetTrustedIPHeader("")
// bucket key is the peer address only. defer SetTrustedIPHeader("")
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("clientIP = %q, want peer 10.0.1.47", got) t.Fatalf("unconfigured: 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)
} }
} }
@@ -32,7 +37,9 @@ func TestClientIPNeverTrustsHeaders(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()
s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway defer SetTrustedIPHeader("")
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)
+5
View File
@@ -28,6 +28,10 @@ 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 {
@@ -39,6 +43,7 @@ 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}
} }
+5 -10
View File
@@ -129,7 +129,7 @@ body {
/* new paste page */ /* new paste page */
.deck { .deck {
display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 16px; display: grid; grid-template-columns: 1fr 300px; gap: 16px;
padding: 16px 20px 20px; height: calc(100vh - 76px); padding: 16px 20px 20px; height: calc(100vh - 76px);
max-width: 1400px; margin: 0 auto; max-width: 1400px; margin: 0 auto;
} }
@@ -354,15 +354,10 @@ 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; }
@@ -533,7 +528,7 @@ td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit;
/* 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.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.slug.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; } td .id-link { 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); }
@@ -894,11 +889,11 @@ button[type="submit"]:focus-visible,
.hidden { display: none; } .hidden { display: none; }
.can-page { max-width: 900px; width: 100%; } .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: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; } .col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; }
.col-f { width: 100px; } .col-g { width: 120px; } .col-f { width: 100px; } .col-g { width: 190px; }
/* #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: 230px; } .col-url { width: 150px; }
/* #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; }
+2 -2
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
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `${t.esc(it.title)}${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>${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>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</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 class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`, `<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.',
+2 -2
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
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `${t.esc(it.title)}${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>${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>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</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 class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` + `<td 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.',
+1 -4
View File
@@ -73,10 +73,7 @@
}); });
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var dark = state().dark; var dark = state().dark;
// #294: use the generic variant resolvers - midnight is dark-first document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
// (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');
-1
View File
@@ -1,6 +1,5 @@
{{define "head"}} {{define "head"}}
<meta charset="utf-8"> <meta charset="utf-8">
<title>{{ pageTitle . }} · Palette</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
+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-url"><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-e"><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>
-33
View File
@@ -74,7 +74,6 @@ func New() (*UI, error) {
"humanSize": humanSize, "humanSize": humanSize,
"version": func() string { return Version }, // #93: topbar version label "version": func() string { return Version }, // #93: topbar version label
"defaultDark": func() bool { return DefaultDark }, // #127: server-configured default "defaultDark": func() bool { return DefaultDark }, // #127: server-configured default
"pageTitle": pageTitle, // page <title> labels
} }
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html") t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
if err != nil { if err != nil {
@@ -83,38 +82,6 @@ func New() (*UI, error) {
return &UI{tmpl: t}, nil return &UI{tmpl: t}, nil
} }
// pageTitle returns the <title> label for the rendered page. Page data is
// always a map with a "Page" key set by the handlers.
func pageTitle(data any) string {
m, _ := data.(map[string]any)
page, _ := m["Page"].(string)
id, _ := m["ID"].(string)
title, _ := m["Title"].(string)
switch page {
case "new":
return "New paste"
case "public":
return "Public pastes"
case "saved":
return "Saved"
case "settings":
return "Settings"
case "paste":
if title != "" {
return title
}
return "Paste " + id
case "unlock":
return "Unlock " + id
case "can":
return "Can " + id
case "notfound":
return "Not found"
default:
return "Palette"
}
}
func humanSize(n int) string { func humanSize(n int) string {
if n < 1024 { if n < 1024 {
return fmt.Sprintf("%d B", n) return fmt.Sprintf("%d B", n)