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

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

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

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

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

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

Tests: ratelimit_test.go (burst/429, refill, unlock limit, highlight, auto-
unlock e2e); existing tests updated for per-test limiter isolation.
2026-09-08 21:09:25 -05:00
poslop 9d75d2f80d password reveal: slash through eye icon while password hidden
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:55:25 -05:00
poslop 6b21f997b5 gitignore: exclude binary and live SQLite WAL files 2026-09-08 20:53:26 -05:00
poslop 103a7a6af5 UI batch 1: copy toast, protection/custom-url spacing, pw reveal, SVG icons, unlock redesign, bfcache result reset, search spinner (#19-#25,#27,#28,#32)
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:52:25 -05:00
poslop 1f162c4003 sweeper: release custom URLs on expiry and after 30-day reservation (#29)
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:50:40 -05:00
poslop 0bbe65ce05 capitalization: fix placeholder casing (#12)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-08 20:29:37 -05:00
poslop db17bd20b0 UI polish: radius tokens, layered shadows, pill selections, default filenames, live search, hidden result card, em dash removal
CI / test (push) Successful in 17s
CI / docker (push) Skipped
Fixes #6 #7 #8 #9 #10 #11 #13 #14 #15
2026-09-08 20:28:13 -05:00
poslop f542ce0407 new: split editor into title bar card, body card, standalone create button
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:01:02 -05:00
poslop c32a6e406d UI: scale up another 1.5x, widen history page container to fit
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:54:35 -05:00
poslop ec8f75d80b UI: scale everything up ~15% for readability on smaller devices
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:51:31 -05:00
poslop efa551c566 new: language auto-detect - /api/guess-language, refresh button, guess on paste
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:41:11 -05:00
poslop 3e0e64dc4f history: pager as its own floating pill below the list
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:34:44 -05:00
poslop 4a467ca999 history: search box under title, Search placeholder
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:32:41 -05:00
poslop 026d9b8c92 topbar: New/Public/Git labels, git opens in new tab
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:28:40 -05:00
poslop 4d98a92b09 history: fix column alignment - scope .row flex rule to new page, colgroup widths, nowrap+ellipsis cells
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:26:07 -05:00
poslop 603b807c51 history: entire row clickable, hover accent on slug
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:17:20 -05:00
poslop acafc13d20 test debian runner
CI / test (push) Successful in 1m12s
CI / docker (push) Skipped
2026-09-08 19:02:36 -05:00
poslop 176ef77737 CI: run jobs on debian-latest runner image
CI / test (push) Failing after 24s
CI / docker (push) Skipped
2026-09-08 18:57:27 -05:00
77 changed files with 4725 additions and 1431 deletions
+6 -2
View File
@@ -8,7 +8,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: [debian-latest]
steps:
- uses: actions/checkout@v4
@@ -26,7 +26,7 @@ jobs:
# build & push image only on tags (releases)
if: startsWith(github.ref, 'refs/tags/')
needs: test
runs-on: ubuntu-latest
runs-on: [debian-latest]
env:
# dind sidecar listens on tcp; job containers reach it via the docker bridge gateway
DOCKER_HOST: tcp://172.17.0.1:2375
@@ -36,6 +36,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install Docker CLI
run: |
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz && tar -xzf /tmp/docker.tgz -C /tmp && mv /tmp/docker/docker /usr/local/bin/docker && chmod +x /usr/local/bin/docker
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
+6 -4
View File
@@ -1,4 +1,6 @@
__pycache__/
node_modules/
*.log
.DS_Store
/palette
palette.db
palette.db-shm
palette.db-wal
admin-key
settings.json
+1 -1
View File
@@ -9,7 +9,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette ./cmd/palette
# ---- runtime stage ----
FROM alpine:3.20
+52 -78
View File
@@ -1,109 +1,83 @@
# Palette
Fast, self-hosted pastebin with paste cans, password lock, expiry, custom URLs, and an API-first design.
Palette is a fast, self-hosted pastebin. One Go binary, a SQLite database, and
a web UI for sharing code and text with links that expire on your terms.
## Quick start
## Features
- Paste cans — bundle notes, text, and files into one shareable page
- Password lock — protect individual pastes with a password
- Custom expiry — from 1 minute up to 1 year, or never
- Burn after N reads — a paste that vanishes after a chosen number of reads
- Custom URLs — reserve `/my-snippet` instead of a random slug
- Syntax highlighting with language auto-detection (go-enry)
- Rate limiting on create and unlock
- Saved page — see and manage everything created from your browser
- API-first — every UI action is also a plain HTTP call
- Single binary — templates and assets are embedded; no external deps
## Get Started
### Build from source
Requires Go 1.21+.
```bash
go build -o palette .
./palette
# UI at http://localhost:8080
# open http://localhost:8080
```
## Docker
### Docker
```bash
docker build -t palette .
docker run -p 8080:8080 -v palette-data:/data palette
docker run -p 8080:8080 -v palette-data:/data git.archfox.org/poslop/palette
```
The SQLite database lives in the `/data` volume inside the container.
## Screenshots
| | |
|---|---|
| ![Editor](docs/palette-previews/pastel-lavender-new.png) | ![Paste view](docs/palette-previews/pastel-lavender-paste.png) |
| ![History](docs/palette-previews/midnight-history.png) | |
## Configuration
| Env var | Default | Description |
| Setting | Default | Description |
|---|---|---|
| `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
### Admin
`GET /admin` serves the admin page. Enter the admin key there — it is stored in
`sessionStorage` (never a cookie) and sent as the `X-Admin-Key` header on
`GET`/`POST /admin/api/settings`.
The admin API reads/sets: rate-limit burst, rate-limit refill per minute, max
content bytes, default expiry, custom URL reservation days, and the burn
viewer window (minutes). All admin access attempts are logged.
```bash
./palette --reset-admin-key # regenerate the admin key and print it
```
## API
### Create paste
Create a paste with one call:
```bash
curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \
-d '{
"content": "print(hello)",
"title": "my snippet",
"language": "python",
"expires_in": "168h",
"password": "optional",
"custom_slug": "optional",
"burn_after_read": false,
"visibility": "public"
}'
-d '{"content": "print(hello)", "language": "python", "expires_in": "168h"}'
```
Response includes `id`, `url`, `raw_url`, `api_url`, and a one-time `deletion_token`.
### Get paste
```bash
curl http://localhost:8080/api/pastes/{id}
# password-protected pastes:
curl "http://localhost:8080/api/pastes/{id}?password=secret"
# or via header: X-Paste-Password: secret
```
### Raw content
```bash
curl http://localhost:8080/raw/{id}
```
### Soft delete
```bash
curl -X DELETE http://localhost:8080/api/pastes/{id}
```
### Hard delete (requires deletion token)
```bash
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
```
### Public history
```bash
curl "http://localhost:8080/api/public?limit=25&offset=0"
```
### Create can (bundle of items)
```bash
curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \
-F "expires_in=48h" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png" \
-F "files=@log.txt"
```
### Get can + items
```bash
curl http://localhost:8080/api/cans/{id}
curl http://localhost:8080/api/cans/{id}/items/{item_id}
```
## Expiry and deletion
- Expired pastes are soft-deleted by a background sweeper (runs every minute).
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- Deletion tokens allow immediate hard delete.
- Burn-after-read pastes are soft-deleted on first read.
## Web pages
- `/new` — create a paste
- `/history` — public paste history
- `/{id}` — view a paste
- `/unlock/{id}` — password gate for protected pastes
- `/raw/{id}` — raw content with original content type
Full API docs: [docs/API.md](docs/API.md).
## CI
-54
View File
@@ -1,54 +0,0 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"github.com/go-chi/chi/v5"
)
// genDeletionToken returns a 32-char url-safe random token
func genDeletionToken() string {
b := make([]byte, 24)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// maybeBurn marks a paste soft-deleted if burn_after_read is set.
// Returns true if this read consumed the paste.
func (s *Store) maybeBurn(row *PasteRow) bool {
if !row.BurnAfterRead {
return false
}
s.SoftDelete(row.ID)
return true
}
func deletionTokenEqual(stored, given string) bool {
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
}
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
// DELETE /api/pastes/{id}/redeem?token=...
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
token := r.URL.Query().Get("token")
if token == "" {
writeErr(w, 400, "token required")
return
}
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.DeletionToken.String == "" || !deletionTokenEqual(row.DeletionToken.String, token) {
writeErr(w, 403, "invalid token")
return
}
// hard delete: pastes table row goes away entirely
a.store.db.Exec(`DELETE FROM pastes WHERE id = ?`, row.ID)
writeJSON(w, 200, map[string]string{"status": "deleted"})
}
+48
View File
@@ -0,0 +1,48 @@
// Command palette is the palette pastebin server entrypoint: flag parsing
// and wiring of the store, API, and web packages.
package main
import (
"log"
"os"
"net/http"
"time"
"palette/internal/api"
"palette/internal/store"
"palette/internal/web"
)
func main() {
// #40: --reset-admin-key regenerates the admin key and exits.
if len(os.Args) > 1 && os.Args[1] == "--reset-admin-key" {
api.HandleResetAdminKey(api.EnvOr("PALETTE_DB", "palette.db"))
return
}
cfg := api.Config{
Addr: api.EnvOr("PALETTE_ADDR", ":8080"),
DBPath: api.EnvOr("PALETTE_DB", "palette.db"),
MaxTextBytes: int64(api.EnvIntOr("PALETTE_MAX_TEXT", 5*1024*1024)),
MaxItemBytes: int64(api.EnvIntOr("PALETTE_MAX_ITEM", 25*1024*1024)),
}
st, err := store.OpenStore(cfg.DBPath)
if err != nil {
log.Fatal(err)
}
adminKey, err := api.ResolveAdminKey(cfg.DBPath)
if err != nil {
log.Fatal(err)
}
ss := api.LoadSettingsStore(cfg.DBPath, cfg)
st.StartSweeper(time.Minute, ss.Get().CustomSlugReservationDays)
ui, err := web.New()
if err != nil {
log.Fatal(err)
}
srv := api.NewServer(st, cfg, ui, ss, adminKey)
log.Printf("palette listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
}
+107
View File
@@ -0,0 +1,107 @@
# Palette API
All endpoints are JSON unless noted. The web UI is served from the same port.
## Create paste
```bash
curl -X POST http://localhost:8080/api/pastes \
-H "Content-Type: application/json" \
-d '{
"content": "print(hello)",
"title": "my snippet",
"language": "python",
"expires_in": "168h",
"password": "optional",
"custom_slug": "optional",
"burn_after_read": false,
"burn_after_reads": 1,
"visibility": "public"
}'
```
- `expires_in` is a Go duration string (`90m`, `6h`, `336h`). Omit for no expiry.
- `visibility` is `public` or `unlisted`.
- `burn_after_reads` sets how many reads the paste survives (default 1 when
`burn_after_read` is true). A read is counted per unique viewer session;
the same viewer returning within 15 minutes does not count again.
- Response includes `id`, `url`, `raw_url`, `api_url`, `expires_at`,
`created_at`, and a one-time `deletion_token`.
Errors: `400` invalid body/content too large/duplicate slug, `401` password
required, `404` paste expired/burned/gone, `413` content exceeds max bytes,
`429` rate limited.
## Get paste
```bash
curl http://localhost:8080/api/pastes/{id}
# password-protected pastes:
curl "http://localhost:8080/api/pastes/{id}?password=secret"
# or via header: X-Paste-Password: secret
```
The response includes `reads_remaining` (`null` when no read budget is set).
## Raw content
```bash
curl http://localhost:8080/raw/{id}
```
Raw reads count against a burn-after-read budget, same as page views.
## Delete
```bash
# soft delete (creator browser only; plain API clients unaffected)
curl -X DELETE http://localhost:8080/api/pastes/{id}
# hard delete immediately (requires the one-time deletion token)
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
```
## Lists
```bash
curl "http://localhost:8080/api/public?limit=25&offset=0" # public history
curl http://localhost:8080/api/mine # this browser's pastes (viewer cookie)
```
## Language detection
```bash
curl -X POST http://localhost:8080/api/guess-language \
-H "Content-Type: application/json" \
-d '{"content": "package main"}'
```
## Cans (bundles of items)
```bash
curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \
-F "expires_in=48h" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png" \
-F "files=@log.txt"
curl http://localhost:8080/api/cans/{id}
curl http://localhost:8080/api/cans/{id}/items/{item_id}
```
## Web pages
- `/new` — create a paste
- `/history` — public paste history
- `/mine` — pastes created from this browser
- `/{id}` — view a paste
- `/unlock/{id}` — password gate for protected pastes
- `/raw/{id}` — raw content with original content type
## Expiry and deletion
- Expired pastes are soft-deleted by a background sweeper (runs every minute).
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
- Deletion tokens allow immediate hard delete.
- Burn-after-read pastes are soft-deleted once the read budget is exhausted.
+160
View File
@@ -0,0 +1,160 @@
# Design: Cookie-Based Preferences and Access Keys (#30)
Status: design note — no implementation yet.
Related: #37 (vwr viewer cookie), #34 (HMAC unlock cookie), #26 (creator auto-unlock), #36 (settings gear).
## Current cookie surface
| Cookie | Purpose | Lifetime | Flags today |
|---|---|---|---|
| `vwr` | Anonymous viewer id; scopes `/mine` history and burn-after-N per-viewer dedupe; client-sent `vwr` also authorizes delete | 1 year | `HttpOnly`, `SameSite=Lax`, `Path=/` |
| `pw_<id>` | Per-paste password unlock token = HMAC(paste id, PALETTE_UNLOCK_SECRET) | 1 hour | `HttpOnly`, `SameSite=Lax`, `Path=/` |
| `tok_<id>` | One-time deletion-token handoff after create | 60 s | `HttpOnly`, `SameSite=Lax`, `Path=/` |
The access-key feature is an extension of the `pw_<id>` pattern, not a new mechanism.
## Part 1: Preference storage
### What settings
Only settings the *creator* sets when writing a paste, so the "new paste" form
can pre-fill them:
- Default language (`lang`)
- Default expiry (`expires_in` / custom expiry)
- Burn-after-N-reads default
- Password-protect-by-default toggle (checkbox pre-checked; the password itself is never stored)
- Default visibility of the "raw" link, if such a toggle exists
- Collapsed/expanded state of the settings gear panel itself
Never stored in cookies: passwords, access keys for pastes the user hasn't
unlocked, deletion tokens (beyond the existing 60 s `tok_` handoff), anything
typed into the paste body or title fields (existing rule: auto-detect must not
overwrite user-typed content).
### One cookie, not many
A single `prefs` cookie holding a compact JSON object:
```
prefs={"lang":"go","exp":"1h","burn":0,"pw":1}
```
- One cookie avoids the browser per-domain cookie count (typically 50+ per
domain; Chrome 180) eating the budget that per-paste access-key cookies need.
- Per-paste cookies (`pw_<id>`) are inherently name-per-paste and cannot be
consolidated — that's the constraint that makes a single `prefs` cookie
mandatory rather than stylistic.
### Size limits
- RFC 6265: user agents SHOULD support at least 4096 bytes per cookie. Keep
`prefs` under 256 bytes of JSON — it holds a handful of short enum values.
- Server behavior: if the cookie is present but oversized/invalid JSON, ignore
it silently and serve defaults. Never reject a request over a bad preference
cookie.
- Validate on the server (allowlist of known values); a cookie is untrusted
input like any header.
### Flags
`HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000; Secure` (Secure once the
prod instance serves HTTPS — it will, behind the letsencrypt IngressRoute;
dev on plain HTTP needs the flag conditional on config).
Preferences are not sensitive, but `HttpOnly` costs nothing and keeps script
from mutating them; `SameSite=Lax` matches the existing cookies.
## Part 2: Access-key cookies
### Goal
"Remember unlocked pastes on this browser" — after entering a password (or
after creating a private paste), subsequent visits skip the unlock form. This
extends `pw_<id>` from a 1-hour session convenience to a durable capability.
### Design: extend `pw_<id>`, don't invent a new scheme
The token is already HMAC(paste id, PALETTE_UNLOCK_SECRET) — unforgeable and
per-paste (fix for the #34 bypass). Changes:
1. **Opt-in checkbox on the unlock form** ("remember on this browser") and a
matching checkbox/note at creation time. Default OFF. Non-consenting
visitors keep the current 1-hour cookie.
2. **Extended lifetime** when opted in: `Max-Age = min(paste expiry, 90 days)`.
The cookie must never outlive the paste — derive the cap from the paste's
`ExpiresAt` at unlock time. Burn-after-N pastes: cap short (e.g. 24 h),
since the paste may burn at any read.
3. **Name collision**: paste ids are fixed-length server-generated, so
`pw_<id>` names stay bounded (~40 bytes each). With the 50-cookies-per-
domain budget, cap remembered pastes at ~30: when minting the 31st, drop
the oldest expired-paste cookies server-side (server knows which ids are
expired/deleted; send expired `Set-Cookie` with `Max-Age=0` to reclaim).
4. **Delete authorization interplay**: today a client-sent `vwr` matching the
paste's ViewerID authorizes delete. Access-key cookies grant *read*
capability only. Do not let a `pw_<id>` cookie authorize deletion — that
would mean cookie theft escalates from "read a paste" to "destroy it".
Delete stays bound to `vwr` or the deletion token.
### Scoping
- Keep `Path=/` (paste URLs are `/{id}` at the root; per-paste `Path=/{id}`
would work but saves nothing and complicates cleanup).
- Per-paste scope via the cookie *name* is the existing, tested pattern —
no shared "access key ring" cookie. A consolidated `keys` cookie would
mean one stolen cookie exposes every remembered paste at once.
## Part 3: Security considerations (honest accounting)
- **XSS exfiltration**: `HttpOnly` prevents JS from *reading* the cookies, but
not from *using* them — an XSS payload can simply `fetch('/<paste-id>')` and
exfiltrate the content through the page the cookie unlocks. HttpOnly raises
the bar (drive-by script can't dump the jar to an attacker server in one
request), it does not make access-key cookies safe. This is a real
limitation, not a solved problem. Mitigations in order of value:
1. Fix the stored-XSS class at the source — #34 already allowlisted
content-types on `/raw`; the standing debt items (CSP, X-Frame-Options,
Referrer-Policy) directly reduce cookie-use exfiltration and should land
before or with this feature.
2. Keep access-key cookies opt-in, so the blast radius is bounded to users
who accepted the tradeoff.
- **Cookie theft = paste access**: anyone holding `pw_<id>` can read that
paste until the cookie or paste expires, from any machine. That is inherent
to capability cookies. Consequences accepted deliberately: pastes here are
ephemeral (1 min1 yr expiry), passwords are low-stakes share convenience,
and there are no user accounts to compromise. Document this in the UI copy
("stores unlock access on this browser").
- **Shared machines**: a remembered cookie defeats the password for the next
user of the browser. The opt-in checkbox with plain-language copy is the
mitigation; do not default it on.
- **Cookie tossing / fixation**: a subdomain attacker could try to force
cookies; palette is a single host, no untrusted subdomains. `SameSite=Lax`
blocks cross-site attachment of the cookies on form posts to unlock
endpoints.
- **Multi-instance / secret rotation**: tokens are HMACs under
`PALETTE_UNLOCK_SECRET`; rotating the secret silently invalidates all
remembered cookies (acceptable — next visit re-prompts). Both dev and prod
k3s instances need the same secret only if sharing a domain, which they do
not.
- **Preferences cookie**: not security-sensitive, but still validate/allowlist
server-side to avoid it becoming an injection sink into templates.
## Recommendation
Implement in two small, separately reviewable pieces:
1. **`prefs` cookie** (do first, low risk): single JSON cookie < 256 bytes,
server-validated allowlist, `HttpOnly; SameSite=Lax; Max-Age=1y`, drives
only form pre-fill. Ship with #36's settings gear.
2. **Extended `pw_<id>` opt-in** (second, security-sensitive): opt-in checkbox,
Max-Age capped by paste expiry (90-day ceiling, 24 h for burn pastes),
oldest-cookie eviction at ~30 pastes, no delete authorization from access
cookies, and land the CSP/X-Frame-Options hardening debt from #34 in the
same or preceding change. UI copy must disclose that the cookie preserves
paste access on the browser.
Rejected alternatives: single consolidated access-key cookie (aggregate theft
risk, and the per-domain cookie-count argument cuts the other way for keys —
consolidation maximizes what one stolen cookie unlocks); localStorage for
preferences (XSS-readable, no benefit over HttpOnly cookies here); server-side
accounts/session table (out of scope — Palette is deliberately anonymous).
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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