Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b068faf8f | ||
|
|
b0360af79c | ||
|
|
806798fae6 | ||
|
|
8977c05cda | ||
|
|
99a044e8a2 | ||
|
|
3d1d5ee1fd | ||
|
|
ccb22a2d28 | ||
|
|
3d415edbdc | ||
|
|
7ca1b58362 | ||
|
|
8d0825ac09 | ||
|
|
4cb8a3e127 | ||
|
|
91dbf6f344 | ||
|
|
b600cdf51a | ||
|
|
47c9fa6386 | ||
|
|
b7d1119d6d | ||
|
|
44fe3c5772 | ||
|
|
556d0fa2e1 | ||
|
|
bdd5235f2e | ||
|
|
91568c0598 | ||
|
|
94a4a3c2ec | ||
|
|
78374b2d49 | ||
|
|
e08cafe9c8 | ||
|
|
2159982795 | ||
|
|
bb5c4f0186 | ||
|
|
9811191648 | ||
|
|
5a2aa0f96d | ||
|
|
c288fc73a7 | ||
|
|
f6c4342468 | ||
|
|
f611cc3e9e | ||
|
|
594b5f01aa | ||
|
|
03bf327f6b | ||
|
|
a604c5906a | ||
|
|
2c1b2818a8 | ||
|
|
b1187e061f | ||
|
|
4f1e901f04 | ||
|
|
a3349b4a98 | ||
|
|
03600b2ed5 | ||
|
|
cb23707125 | ||
|
|
15ce7ff011 | ||
|
|
238dc96454 | ||
|
|
d5a47b1a31 | ||
|
|
127c12c79a | ||
|
|
2e1ce508fa | ||
|
|
76d7ac12e7 | ||
|
|
129934b645 | ||
|
|
e83303a428 | ||
|
|
acf71f7444 | ||
|
|
9c4689e6de | ||
|
|
ebcf7eec80 | ||
|
|
b3112d2a35 | ||
|
|
6a6f4d1587 | ||
|
|
e31aa44ecb | ||
|
|
5b7198fb3c | ||
|
|
efa8c7145c | ||
|
|
7410b5c9cf | ||
|
|
3facff3d1e | ||
|
|
9d75d2f80d | ||
|
|
6b21f997b5 | ||
|
|
103a7a6af5 | ||
|
|
1f162c4003 | ||
|
|
0bbe65ce05 | ||
|
|
db17bd20b0 | ||
|
|
f542ce0407 | ||
|
|
c32a6e406d | ||
|
|
ec8f75d80b | ||
|
|
efa551c566 | ||
|
|
3e0e64dc4f | ||
|
|
4a467ca999 | ||
|
|
026d9b8c92 | ||
|
|
4d98a92b09 | ||
|
|
603b807c51 | ||
|
|
acafc13d20 | ||
|
|
176ef77737 |
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
__pycache__/
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
/palette
|
||||
palette.db
|
||||
palette.db-shm
|
||||
palette.db-wal
|
||||
admin-key
|
||||
settings.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,109 +1,93 @@
|
||||
# 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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  | |
|
||||
|
||||
## 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`.
|
||||
Full API docs: [docs/API.md](docs/API.md). Design docs: [docs/design/](docs/design/) (currently: [client-side E2E encryption](docs/design/e2e-encryption.md), issue #39).
|
||||
|
||||
### 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
|
||||
```
|
||||
## Performance Notes
|
||||
|
||||
### 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
|
||||
The history and Saved pages use client-side filtering: when you type in the
|
||||
search box, the UI fetches the most recent 100 pastes (`limit=100`, the API
|
||||
maximum) once per query and filters/sorts them in the browser. Pastes beyond
|
||||
the newest 100 are not searched; a match count against the full total is still
|
||||
shown. This keeps search instant without a server-side query. If large
|
||||
instances need full search later, it will be a server-side endpoint (see
|
||||
issue #32).
|
||||
|
||||
## CI
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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`.
|
||||
- Alternatively (or additionally), `public` may be sent as a boolean (#83):
|
||||
`false` maps to `unlisted` and `true` maps to `public`. When both fields are
|
||||
present, the boolean `public` takes precedence over the string `visibility`.
|
||||
Omitting both defaults to `public`.
|
||||
- `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 (requires the deletion token from the create response)
|
||||
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/{id}
|
||||
# ...or via query param; the creator browser (viewer cookie) may also delete without a token
|
||||
curl -X DELETE "http://localhost:8080/api/pastes/{id}?token=TOKEN"
|
||||
|
||||
# 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.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Attachments & Storage Backend Design (#38, #31)
|
||||
|
||||
Status: research/design, no implementation. Consumers: paste cans (#4).
|
||||
|
||||
## Part A — Attachments: S3/MinIO vs filesystem-on-volume (#38)
|
||||
|
||||
### Options
|
||||
|
||||
**Option 1: Filesystem on the k3s PVC (current 5Gi volume).**
|
||||
Store blobs under `<data-dir>/attachments/<paste-id>/<n>-<sha256-8>`, metadata in SQLite (paste_id, filename, size, sha256, mime, created_at).
|
||||
|
||||
- Pros: zero new infra, zero new credentials, trivial backup (the volume backup job already covers the DB), atomic rename on write, works in dev and prod identically.
|
||||
- Cons: volume is size-capped (5Gi today; resizable but bounded); serving large files passes through the app process (no ranged-GET offload); multi-replica later would need RWX volume.
|
||||
|
||||
**Option 2: MinIO via S3 API.**
|
||||
MinIO is already proven in this homelab (Outline). Store at key `<paste-id>/<n>`; same SQLite metadata row.
|
||||
|
||||
- Pros: effectively unbounded capacity, presigned URLs (direct browser download, offloads serving from palette pods), ranged requests free, lifecycle rules could auto-expire orphaned objects.
|
||||
- Cons: another credential/secret to manage, another failure mode, MultipartForm still terminates at the palette pod (MinIO only helps *serving*, not *uploading*, unless we do presigned uploads — which breaks the cans multipart flow and the auth/unlock checks), backup now spans two systems.
|
||||
|
||||
### Key considerations
|
||||
|
||||
- **Upload path is the same either way.** Palette receives `multipart/form-data` (cans need text items + file drops in one request), must enforce auth/password/burn rules server-side. A filesystem backend adds no upload complexity; S3 adds an extra hop (buffer → PUT to MinIO). Streaming straight from `multipart.Reader` to the sink works for both (`io.Copy` to a temp file, or to an S3 PUT with `Content-Length` known or multipart buffering).
|
||||
- **Size limits.** Everything is admin-tunable via the settings API (#40 pattern) — add `max_attachment_bytes` (default 10 MiB, hard server-side cap checked *before* reading the body via `Content-Length`, plus a counted reader during copy so chunked uploads can't lie). SQLite itself is not a constraint either way; the PVC is the real cap for Option 1.
|
||||
- **MIME handling.** Security-critical (pentest #34 already fixed a content-type XSS on `/raw`). Rules:
|
||||
- Never trust the client-declared Content-Type. Sniff the first 512 bytes (`http.DetectContentType`), intersect with an allowlist.
|
||||
- Serve from a dedicated route (`/{id}/a/{n}`) with `Content-Type` from the stored *sniffed* type, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`, `Content-Disposition: attachment` unless the type is on a safe-inline allowlist (text/plain, images, PDF at user opt-in).
|
||||
- Never render user HTML/SVG inline (`image/svg+xml` is XSS-capable — serve as `attachment` always, or store sanitized).
|
||||
- **Streaming & serving.**
|
||||
- Filesystem: `http.ServeContent` on the opened file gives ranged GETs, ETag, Last-Modified for free.
|
||||
- MinIO: proxy via `GetObject` + `io.Copy` (simple, keeps auth checks in palette) or presigned GET (faster, but URL embeds credentials-temporarily and bypasses palette's per-request auth — wrong for pastes with passwords/burn semantics). Given cans inherit password/burn parity (#4), **proxying is required, which erodes MinIO's main serving advantage**.
|
||||
- **Lifecycle parity.** Attachments must honor soft-delete grace and sweep: sweeper hard-delete also removes blobs (files: `os.Remove`; S3: `DeleteObject`), best-effort with logging; orphan sweep job compares DB rows to store contents.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Filesystem-on-volume first.** At current scale (single replica, PVC-based deploy, one user + homelab traffic) it is simpler end-to-end and keeps serving/auth/lifecycle in one place. The internal API should be a narrow blob interface (`Put(ctx, key, r io.Reader, size int64) / Open(key) / Delete(key)`) — about 60 lines per backend — so **MinIO becomes a drop-in later** if attachments outgrow the volume. That's the honest middle path: filesystem default, S3-ready seam, no MinIO dependency until it pays for itself.
|
||||
|
||||
## Part B — SQLite vs Postgres vs Redis (#31)
|
||||
|
||||
### Assessment of SQLite at pastebin scale
|
||||
|
||||
- **Driver**: modernc.org/sqlite (pure Go, no cgo) — slightly slower than mattn/go-sqlite3 but fine; single-writer semantics are the real constraint, not driver speed.
|
||||
- **Access pattern**: paste-heavy, write-rare/read-often; primary keys and small set of indexes (visibility+created, expires, deleted); no joins beyond can items. This is SQLite's best case.
|
||||
- **WAL mode** is already on (`journal_mode(WAL), busy_timeout(5000)`) — concurrent readers don't block the single writer.
|
||||
- **Numbers**: SQLite comfortably handles millions of rows and hundreds of reads/sec; WAL write throughput is thousands of small inserts/sec. A pastebin doing even 100k pastes (avg 10 KB = ~1 GB DB) is trivial. Reads: prepared `WHERE id=?` lookups at this size are sub-millisecond.
|
||||
- **Weak points to watch** (document, none urgent):
|
||||
1. Single writer — heavy concurrent create traffic serializes. Mitigation: already rate-limited (#2); fine until that's the bottleneck (unlikely).
|
||||
2. `LENGTH(content)` on every list row — fine now; if it shows up in profiling, store `size` as a column (schema already has a `Size` field; list queries could use it).
|
||||
3. Sweeper runs a table-wide `UPDATE`+`DELETE` on tick — indexed, fine.
|
||||
4. No network access to the DB file — locks palette to single-replica. Acceptable: current deploy is one replica.
|
||||
- **Redis is the wrong tool** here: it's a cache/queue, not a system of record. Pastes are durable data with expiry semantics already implemented in SQLite. Redis would only add an optional read-cache layer for hot pastes — pure complexity for zero measured need.
|
||||
|
||||
### Should we build a backend abstraction (SQLite default, optional Postgres)?
|
||||
|
||||
Arguments for: multi-replica scaling later; "docker image env choice" sounds nice; Postgres gives real concurrency and network access.
|
||||
Arguments against: a `Store` interface covering the current query surface is a real refactor (sqlite-flavored SQL: `INSERT OR IGNORE`-style upserts, partial indexes, `?` placeholders are compatible but behaviors differ — e.g. `sqlite` driver pragmas, transaction isolation, `AUTOINCREMENT` semantics); two backends means two test matrices and two migration paths forever; and there is **no current need** — single replica, single writer, modest data.
|
||||
|
||||
**Recommendation: stay SQLite-only. Do not build the abstraction now.**
|
||||
Specifically:
|
||||
|
||||
1. Keep all persistence behind `internal/store` (already done in #35 — the package boundary *is* the abstraction, at zero cost).
|
||||
2. Avoid SQLite-specific SQL going forward where free (standard placeholders, no `RETURNING` quirks) — cheap discipline that keeps a future port honest.
|
||||
3. Define the trigger conditions for revisiting, and write them down:
|
||||
- multiple replicas needed (scale-out), or
|
||||
- sustained WAL write contention (busy timeouts observed in logs), or
|
||||
- DB file > ~5-10 GB, or
|
||||
- a concrete user request for a Postgres-backed image.
|
||||
4. When a trigger fires, port `internal/store` to Postgres behind an interface extracted *then* — the refactor is mechanical against a real need, instead of speculative complexity now.
|
||||
5. For the docker image: `PALETTE_DB_PATH` env already implies the deployment choice; no extra backend knob needed.
|
||||
|
||||
## Summary
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Attachment backend | Filesystem on PVC, behind a ~3-method blob interface; MinIO as a later drop-in, not a dependency |
|
||||
| Size limits | `max_attachment_bytes` admin setting, default 10 MiB, enforced pre-read + during stream |
|
||||
| MIME | Server-side sniff (512 bytes) + allowlist; `nosniff`, CSP `sandbox`, `Content-Disposition: attachment` except safe-inline types; SVG never inline |
|
||||
| Database | SQLite (WAL, modernc) only; no Postgres/Redis, no backend abstraction until a written trigger fires |
|
||||
@@ -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 min–1 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).
|
||||
@@ -0,0 +1,223 @@
|
||||
# Design: Optional client-side E2E encryption for pastes and files
|
||||
|
||||
- **Issue:** #39
|
||||
- **Status:** Design (no implementation in this PR)
|
||||
- **Related docs:** [docs/API.md](../API.md)
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- Let any paste (or can item) be stored server-side as ciphertext only.
|
||||
- Zero plaintext knowledge by the server: storage, logs, backups, DB dumps contain no readable content.
|
||||
- Pure browser implementation using WebCrypto; no new server dependencies.
|
||||
- Encrypted pastes must still work with expiry, hard/soft delete, deletion tokens, visibility, slugs, rate limits.
|
||||
|
||||
**Non-goals (v1)**
|
||||
|
||||
- Anonymous, account-less E2E; Palette stays server-trusting with browser cookies.
|
||||
- Sharing via link fragments (`#key`) is optional sugar, not a required transport.
|
||||
- Search *of encrypted content*, server-side language detection, or server-side highlighting on encrypted pastes — these are structurally impossible and out of scope (see §5).
|
||||
- Signing, deniability, forward secrecy across pastes, PFS, post-quantum crypto.
|
||||
|
||||
**Threat model (explicit).** This protects against a *passive server compromise* — a DB dump, backup leak, or disk image of the server's SQLite file. It does **not** protect against:
|
||||
|
||||
- A fully malicious / compromised Palette server serving backdoored JavaScript: any JS-delivered crypto can be backdoored (key exfiltration via JS) regardless of primitives. This is the fundamental limit of a JS-in-browser E2E scheme.
|
||||
- Malware on the viewer's device, or shoulder-surfing of the password.
|
||||
- Traffic analysis, timing, or metadata (title, size, expiry, IP, viewer cookie).
|
||||
- A attacker who compromises the server *while the creator's browser is open* and alters JS before encrypt.
|
||||
|
||||
Be explicit in user-facing copy: "encrypted at rest; the server cannot read your paste" is accurate — "the server can never see your paste" is not.
|
||||
|
||||
## 2. Crypto primitives and flow
|
||||
|
||||
### 2.1 Recommended parameters
|
||||
|
||||
| Parameter | Recommendation | Notes |
|
||||
|---|---|---|
|
||||
| Cipher | **AES-256-GCM** | `AES-GCM` with a 256-bit key, per-paste random 96-bit IV/nonce. WebCrypto built-in, hardware-accelerated, authenticated. |
|
||||
| KDF | **PBKDF2-HMAC-SHA-256** | 600,000 iterations (OWASP 2023+ recommendation), 16-byte random salt. |
|
||||
| Argon2id | **Not in v1** | WebCrypto has no Argon2id; a JS/WASM Argon2 implementation is an extra supply-chain dependency and is an asymmetric liability: a script the server could swap can't be load-bearing for security anyway. Add later via `argon2id` WASM with SRI pinning + CSP (`script-src 'self'`) if needed. |
|
||||
| Salt | 16 random bytes per paste, stored in the clear alongside ciphertext | Unique per paste, never reused. |
|
||||
| IV | 12 random bytes per encryption | With ~2^32 encryptions per key this is negligible; each paste has its own key anyway. |
|
||||
| Key check value | See §2.2 | Catches wrong passwords without a server round-trip and prevents trash writes. |
|
||||
|
||||
### 2.2 Flow (create)
|
||||
|
||||
1. User checks "Encrypt" and enters an encryption passphrase (distinct from any access password) in `/new`.
|
||||
2. Browser generates `salt` (16 B) and `iv` (12 B) via `crypto.getRandomValues`.
|
||||
3. `crypto.subtle.importKey("raw", passphrase, "PBKDF2", false, ["deriveKey"])` →
|
||||
`crypto.subtle.deriveKey(PBKDF2-SHA-256, 600k iterations, salt, {name:"AES-GCM", length:256}, false, ["encrypt","decrypt"])`.
|
||||
4. Generate a 32-byte random **DEK** (`crypto.getRandomValues(32)`).
|
||||
5. Content encryption key check: `iv_ckv`, `encrypted_content = AES-GCM-256(DEK, iv, content)`.
|
||||
6. **Key check value (KCV):** compute `AES-GCM-DEK(random 16 bytes)` — a small token encrypted *under the DEK*, stored as `key_check` blob. This is decrypted with the derived key; on wrong password GCM auth fails and the client can show "wrong key" without asking the server to burn a read.
|
||||
7. Wrap the DEK with the KEK: `wrapped_dek = AES-GCM(KEK, iv_wrap, dek)`.
|
||||
8. The stored envelope format:
|
||||
```
|
||||
{
|
||||
v: 1, kdf: "PBKDF2-SHA256", iterations: 600000, salt_b64, iv_b64,
|
||||
kdf_salt_b64, wrap_iv_b64, wrapped_dek_b64, key_check_b64, ciphertext_b64
|
||||
```
|
||||
The `v` field allows migrating to Argon2id later without a breaking change.
|
||||
8. POST the envelope (base64) as `content`, with an `encryption` metadata object alongside (see §3.1).
|
||||
|
||||
### 2.3 Flow (view)
|
||||
|
||||
1. User provides the passphrase via form field, or the key arrives in the URL `#fragment`. The encryption passphrase is a separate field from any access password.
|
||||
2. Fetch `/api/pastes/{id}` (with access password in the usual field if the paste is also password-gated).
|
||||
3. Derive KEK from passphrase+salt, unwrap DEK via key_check / unwrap step.
|
||||
4. Decrypt content with the DEK; on `OperationError` → "wrong passphrase" UI state (retries are client-side only; no re-fetch, so no extra burn-after-read charge).
|
||||
5. Language detection happens client-side (e.g. highlight.js auto-detect) on the decrypted plaintext.
|
||||
|
||||
### §2.4 File and can items
|
||||
|
||||
Files in cans: encrypt each file with its own DEK and store the same envelope. Cans' `json_items` content fields each carry their own envelope. Files keep their mime type in cleartext metadata; only the bytes are encrypted. The can's title stays plaintext (unless the whole can is encrypted, v2).
|
||||
|
||||
## 3. API shapes
|
||||
|
||||
### 3.1 Create request
|
||||
|
||||
Existing fields unchanged. New optional `encryption` object:
|
||||
|
||||
```json
|
||||
POST /api/pastes
|
||||
{
|
||||
"content": "<base64 envelope>",
|
||||
"encryption": {"v": 1, "kdf": "PBKDF2-SHA256", "iterations": 600000,
|
||||
"salt": "b64", "iv": "b64", "key_check": "b64"}
|
||||
}
|
||||
```
|
||||
|
||||
`encryption` is non-secret KDF metadata for UI display; the server treats `content` as opaque bytes and MUST NOT inspect it for encrypted pastes (no detection, no highlighting prep, no search indexing) — enforced where content is written, not per-handler.
|
||||
|
||||
The full envelope can also just live inside `content` (server-opaque); the `encryption` object carries only non-secret KDF metadata the list views need (e.g. to show a 🔒 icon).
|
||||
|
||||
### 3.2 Create response
|
||||
|
||||
Unchanged shape: `id`, `url`, `raw_url`, `api_url`, and the one-time `deletion_token` documented in docs/API.md.
|
||||
|
||||
### 3.3 Get response
|
||||
|
||||
`GET /api/pastes/{id}` response gains:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "abc123",
|
||||
"content": "BASE64_ENVELOPE",
|
||||
"encryption": {"v":1, "kdf": "PBKDF2-SHA256", "iterations": 601570, "salt": "b64", "iv": "base64", "key_check": "b64"},
|
||||
"reads_remaining": null
|
||||
}
|
||||
```
|
||||
|
||||
`language` is `"encrypted"` or `null` so clients don't run detection on ciphertext. `raw_url` also serves the envelope; the `/{id}` page ships it to the browser, which decrypts in place.
|
||||
|
||||
### 3.4 Raw endpoint
|
||||
|
||||
`GET /raw/{id}` returns the envelope as `application/octet-stream` with a suggested filename like `{id}.e2e.txt` and `Content-Disposition: attachment`. This is deliberate: a "download encrypted blob" is what a non-browser client can do with it anyway.
|
||||
|
||||
### 3.5 List views / mine / public
|
||||
|
||||
List endpoints return `has_encryption: true` instead of content; show a lock icon. Do not include ciphertext in list responses (size, and no reason to ship ciphertext to every viewer's list view) — `GET /api/pastes/{id}` remains the only endpoint that returns the envelope.
|
||||
|
||||
`/api/mine` (creator's own browser) may include the envelope for convenience; `/api/public` returns metadata only.
|
||||
|
||||
`/api/guess-language` rejects encrypted content with `400 "content is client-encrypted"` — detection needs plaintext; clients detect after decrypting.
|
||||
|
||||
Delete, redeem, rate limits, expiry, sweeper, deletion tokens, visibility, slugs, and can CRUD are unchanged — the server never inspects content for these, so opaque content is a no-op path.
|
||||
|
||||
## 4. Interplay with existing features
|
||||
|
||||
| Feature | Impact | Mitigation |
|
||||
| Burn-after-read | Budget is charged on fetch, exactly as today; the server cannot know whether decryption succeeded, so a viewer fetching with the wrong key burns a read they can't use. | Decrypt retries are client-side, so only the first fetch charges the budget. Clear UX copy. |
|
||||
| Password-protected + encrypted | Both can coexist and are independent: the access password is an HTTP 401 gate; the encryption passphrase never leaves the browser. If both are set, all three secrets are needed (URL + access password + passphrase). Warn if the user enters the same value in both fields. |
|
||||
| Encryption-only pastes | Supported with no access password: URL + passphrase (or fragment key). Default is passphrase; fragment key is opt-in with a warning. |
|
||||
| Search | Structurally impossible over ciphertext. Server search just skips encrypted pastes; client-side search within a single decrypted paste works fine. No global encrypted-content search — accept the loss, document it. |
|
||||
| Language detection / highlighting | Server-side detection/highlighting impossible; returns `language: null`. Client-side detection via highlight.js auto-detect on decrypted plaintext (client already loads it for password gate pages). |
|
||||
| Cans/files | Per-item envelopes (own DEK each), per §2.4. | Consider a can-level KEK (one passphrase unlocks all items). |
|
||||
| Expiry/sweeper/delete/redeem | Unchanged — server never inspects content for these. |
|
||||
| List views (`/api/public`, `/api/mine`) | Additive `has_encryption: true` flag; list responses do not include ciphertext (`/api/mine` may include the envelope for the creator's own convenience). |
|
||||
| guess-language endpoint | Reject with 400. |
|
||||
| Fork / edit | Re-encryption needs the passphrase in the browser; v1 disables forking encrypted pastes. | Document the limitation. |
|
||||
|
||||
## 5. What breaks, stated plainly
|
||||
|
||||
- **Search across encrypted pastes: impossible.** Accept the loss. (If ever needed, client-side index in IndexedDB for the creator's own pastes — v2+.)
|
||||
IndexedDB only helps the creator, not other viewers; still not global search. Accept the loss.
|
||||
- **Server-side language detection and highlighting: impossible.** Client-side detection on decrypted plaintext. Server returns `language: null` and the client detects.
|
||||
- **Burn-after-read is weakened in one specific way:** the budget is counted on fetch, not on successful decryption. A viewer who fetches but can't decrypt (wrong/lost key) burns a read they can't use. Mitigations documented in §4 table. The server can still count fetches (which is what burn-after-read actually is, even today: it counts fetches, not "reads" in any content-aware sense). So burn-after-read still works — it counts fetches — it's just that a failed decryption still consumes budget. This is acceptable and just needs UX copy. Optionally: don't decrement on failed decryption is *not possible* the server can't tell, so it's fetch-based, period. (It already is today.)
|
||||
- **Raw endpoint semantics change:** `/raw/{id}` can no longer serve readable raw text. It serves the ciphertext envelope. Scripts that curl raw pastes will get base64 envelope instead of text. Document as a breaking-ish change for encrypted pastes only; unencrypted pastes unchanged.
|
||||
- **Existing /api/mine, /api/public list shapes gain a flag** (additive, non-breaking).
|
||||
- **Copy-to-clipboard of decrypted text stays client-side**, fine. "Copy raw" on an encrypted paste copies the envelope — label it clearly.
|
||||
|
||||
## 5. UX for key sharing
|
||||
### 5.1 Three sharing modes
|
||||
|
||||
| Mode | What's shared | Security level | Use case |
|
||||
|---|---| malformed JSON / wrong key | | |
|
||||
| Mode | What's shared | Strength | Use case |
|
||||
|---|---|---|---|
|
||||
| **Passphrase** (default) | URL + passphrase out-of-band (Signal etc.) | Good — two channels | Team snippets, sensitive configs |
|
||||
| **Passphrase + access password** | URL + access password (401 gate) + passphrase | Strong — two secrets, two channels | Highest sensitivity |
|
||||
| **Random key in `#fragment`** | URL containing `#key=<b64>` | Weak — single channel; anyone with the full URL has both parts. Copy/paste into chat defeats it entirely. | One-click convenience sharing |
|
||||
|
||||
Browsers never transmit `#` fragments to servers; still set `Referrer-Policy: no-referrer` site-wide and offer separate copy buttons for URL and key. Key-in-fragment ships with a warning and stays opt-in.
|
||||
|
||||
### 5.2 Create page (`/new`) UX
|
||||
|
||||
- "Encrypt content" toggle → reveals passphrase field + strength meter + generate-random-key button.
|
||||
- When encrypting, hide the server-side language dropdown; the client detects language after decryption.
|
||||
- Two separate inputs with distinct labels: "Access password (checked by the server, 401 gate)" and "Encryption passphrase (never leaves your browser)". If both hold the same value, warn.
|
||||
|
||||
### 6.2 View page (`/{id}`) UX
|
||||
|
||||
- If `encryption.kdf` is present → show key entry UI (after the access-password 401 gate, if that also applies).
|
||||
- After decrypt: normal render pipeline, language detected client-side.
|
||||
- "Wrong passphrase" retries never re-fetch, so they never burn extra reads.
|
||||
|
||||
## 7. Backwards compatibility and migration
|
||||
|
||||
- Additive JSON fields only; unencrypted pastes behave identically. No schema changes (envelope is stored in the existing content column/TEXT; verify column size allows envelope overhead (~2× base64 + ~200 B header).
|
||||
- Server-side validation of encrypted pastes: only structural checks (base64 decodes, size ≤ max bytes). No crypto in the server.
|
||||
|
||||
**Server implementation cost is genuinely small** (est. 2-4 days): pass through content untouched, add `encryption` metadata column or embed in content, skip detection/indexing when `encryption` is present, list flag. The server never does crypto. All crypto is client-side JS (~150-300 lines, no build-step change if using WebCrypto alone).
|
||||
|
||||
**Argon2id later:** add `kdf: "argon2id"` to the envelope `v: 1` (m=64 MiB, t=3, p=1) via a SRI-pinned WASM module, with CSP `script-src 'self'` + SRI on the script tag. Envelope `v` field already allows this.
|
||||
|
||||
## 8. Recommendation
|
||||
|
||||
**Build it, as an opt-in checkbox, passphrase mode only in v1.**
|
||||
|
||||
- Server cost is small (pass-through + skip detection/indexing + list flag), client cost moderate (WebCrypto only, no new deps).
|
||||
- It closes the biggest real-world risk for a public pastebin: a DB/backup leak exposing every paste ever written.
|
||||
- Skip Argon2id in v1; envelope `v` field provides a migration path.
|
||||
- Key-in-fragment mode: build the plumbing (fragment parsing) but hide behind "advanced"; default remains passphrase.
|
||||
|
||||
**Do not build:** server-side search over encrypted content, server-side highlighting of encrypted content, decrypt-on-server "preview" mode, or any server-side crypto.
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. Size limits: base64 expansion (~4/3×) plus ~200 B envelope overhead; the existing max-bytes / 413 limit applies to the envelope bytes the server stores. Do not compress before encrypting (CRIME-style weaknesses).
|
||||
2. Fork/edit of encrypted pastes: disabled in v1, revisit.
|
||||
3. Should `/api/mine` include the full envelope in list view? Leaning yes (creator's own browser can decrypt); note the larger payload.
|
||||
4. Should there be a "verify passphrase" second field at create time (type-twice), or rely on the KCV check at view time? KCV at view time suffices; type-twice adds friction at create. Rely on KCV, skip type-twice.
|
||||
5. CSP/Referrer-Policy hardening: `Referrer-Policy: no-referrer` site-wide is worth doing regardless of this feature (it also benefits unencrypted pastes).
|
||||
6. Cans: per-item DEKs wrapped by a single can-level KEK (one passphrase unlocks all items) — better UX, slightly more envelope design work. Defer detail to implementation.
|
||||
|
||||
## 10. Alternatives considered
|
||||
|
||||
| Alternative | Why not in v1 |
|
||||
|---|---|
|
||||
| Argon2id via WASM in v1 | Extra JS dependency the server could swap → can't be load-bearing; PBKDF2-600k is adequate for a pastebin. Defer. |
|
||||
| Server holds half a key (2-of-2 with server-held share) | Re-introduces server trust; defeats the purpose. |
|
||||
| age-format envelopes | Nice CLI interop but no WebCrypto-native support; adds a JS dependency. Defer. |
|
||||
| PGP / S-MIME | Poor browser UX; heavy dependencies. |
|
||||
| Server-side encryption with server-held keys | Not E2E; that's "encrypted at rest", already covered by disk-level encryption. |
|
||||
| PrivateBin-style fragment key only | Single-channel sharing is a footgun; keep passphrase as default. |
|
||||
| libsodium / tweetnacl | Solid but unnecessary; WebCrypto covers AES-GCM + PBKDF2 natively. |
|
||||
|
||||
## 11. References
|
||||
|
||||
- OWASP Password Storage Cheat Sheet (PBKDF2 guidance): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
||||
- MDN WebCrypto: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
|
||||
- PrivateBin (prior art for fragment-key sharing): https://privatebin.info
|
||||
- 0bin, Hemmelig — other pastebin/secret E2E prior art.
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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 !rateLimitAdmin(r) {
|
||||
log.Printf("admin auth RATE LIMITED: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||
writeRateLimited(w, 60)
|
||||
return
|
||||
}
|
||||
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() }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
// #66: admin key attempts must be rate limited per IP (5/min), constant-time
|
||||
// compared, and failures logged. Hammering bad keys must yield 429s.
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdminKeyRateLimited: burst of 5 bad-key attempts allowed (401), the 6th
|
||||
// gets 429, and even the correct key is blocked from that IP until refill.
|
||||
func TestAdminKeyRateLimited(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
h := srv.routes()
|
||||
reqIP := "10.7.7.1:1234"
|
||||
|
||||
var got429, retryAfter bool
|
||||
var lastCode int
|
||||
for i := 0; i < 10; i++ {
|
||||
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
|
||||
req.RemoteAddr = reqIP
|
||||
req.Header.Set("X-Admin-Key", "wrong-key")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
lastCode = rec.Code
|
||||
if rec.Code == 429 {
|
||||
got429 = true
|
||||
retryAfter = rec.Header().Get("Retry-After") != ""
|
||||
break
|
||||
}
|
||||
}
|
||||
if !got429 {
|
||||
t.Fatalf("expected 429 after hammering bad keys, last status %d", lastCode)
|
||||
}
|
||||
if !retryAfter {
|
||||
t.Error("429 missing Retry-After header")
|
||||
}
|
||||
|
||||
// Correct key from the same IP is also locked out.
|
||||
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
|
||||
req.RemoteAddr = reqIP
|
||||
req.Header.Set("X-Admin-Key", srv.adminKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 429 {
|
||||
t.Errorf("correct key after lockout: got %d, want 429", rec.Code)
|
||||
}
|
||||
|
||||
// A different IP is unaffected.
|
||||
req2 := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(`{"rate_limit_burst":5,"rate_limit_per_minute":60,"max_content_bytes":1048576,"custom_slug_reservation_days":30,"burn_viewer_window_minutes":15}`))
|
||||
req2.RemoteAddr = "203.0.113.9:1234"
|
||||
req2.Header.Set("X-Admin-Key", srv.adminKey)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != 200 {
|
||||
t.Errorf("correct key from another IP: got %d, want 200", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminKeyConstantTimeCompare: sanity check that the comparison is
|
||||
// constant-time (uses subtle.ConstantTimeCompare, not ==).
|
||||
func TestAdminKeyConstantTimeCompare(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("X-Admin-Key", "test-admin-key")
|
||||
if !srv.adminKeyOK(r, srv.adminKey) {
|
||||
t.Fatal("correct key rejected")
|
||||
}
|
||||
r.Header.Set("X-Admin-Key", "wrong")
|
||||
if srv.adminKeyOK(r, srv.adminKey) {
|
||||
t.Fatal("wrong key accepted")
|
||||
}
|
||||
// differ in length: must not panic/mismatch unexpectedly
|
||||
r.Header.Set("X-Admin-Key", "test-admin-key-longer")
|
||||
if srv.adminKeyOK(r, srv.adminKey) {
|
||||
t.Fatal("longer wrong key accepted")
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBurnAfterReadConcurrentRace is the #58 regression test: N concurrent
|
||||
// readers of a burn-after-read paste must receive exactly one success with
|
||||
// content; every other reader must get 404 and never any content.
|
||||
func TestBurnAfterReadConcurrentRace(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id := createBurnReads(t, h, 1)
|
||||
|
||||
const readers = 24
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
wins, losses := 0, 0
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if rec.Code == 200 {
|
||||
wins++
|
||||
var got struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Content != "limited" {
|
||||
t.Errorf("winning read returned wrong content: %v %q", err, got.Content)
|
||||
}
|
||||
} else if rec.Code == 404 {
|
||||
losses++
|
||||
if strings.Contains(rec.Body.String(), "limited") {
|
||||
t.Errorf("losing read leaked content: %s", rec.Body.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if wins != 1 {
|
||||
t.Fatalf("expected exactly 1 winning read of burn paste, got %d (losses=%d)", wins, losses)
|
||||
}
|
||||
if losses != readers-1 {
|
||||
t.Fatalf("expected %d losing reads, got %d", readers-1, losses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBurnAfterNReadsConcurrentBudget hammers a burn-after-N paste with many
|
||||
// more concurrent distinct readers than the budget: total admissions must
|
||||
// equal exactly N, and no losing read may see content.
|
||||
func TestBurnAfterNReadsConcurrentBudget(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
const budget = 3
|
||||
id := createBurnReads(t, h, budget)
|
||||
|
||||
const readers = 30
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
wins := 0
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if rec.Code == 200 {
|
||||
wins++
|
||||
} else if rec.Code != 404 {
|
||||
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if wins != budget {
|
||||
t.Fatalf("expected exactly %d admitted reads, got %d", budget, wins)
|
||||
}
|
||||
|
||||
// After the race, the paste is burned for everyone.
|
||||
if rec := getWithCookie(t, h, id, "after-the-fact"); rec.Code != 404 {
|
||||
t.Fatalf("paste should be burned after budget exhausted, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package main
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"palette/internal/store"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -40,12 +40,18 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "invalid expires_in")
|
||||
return
|
||||
}
|
||||
// #60: clamp at the API boundary like the pastes API does -
|
||||
// reject zero/negative and durations past the 1-year UI cap.
|
||||
if !store.ValidExpiry(d) {
|
||||
writeErr(w, 400, "expires_in must be between 1 minute and 1 year")
|
||||
return
|
||||
}
|
||||
t := now + int64(d.Seconds())
|
||||
expiresAt = &t
|
||||
}
|
||||
var pwHash *string
|
||||
if password != "" {
|
||||
h, err := hashPassword(password)
|
||||
h, err := store.Argon2IDHash(password)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "hash error")
|
||||
return
|
||||
@@ -53,9 +59,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 +81,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 +109,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 +119,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,46 +149,6 @@ 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")
|
||||
can, err := a.store.GetCan(id)
|
||||
@@ -204,7 +169,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 +189,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 +218,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))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// #60: the cans API must clamp expires_in at the boundary exactly like the
|
||||
// pastes API — reject zero/negative durations and anything over the 1-year
|
||||
// UI cap, accept the exact boundaries.
|
||||
func TestCreateCanExpiryBounds(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
cases := []struct {
|
||||
expiresIn string
|
||||
wantCode int
|
||||
}{
|
||||
{"-1h", 400}, // negative
|
||||
{"-0s", 400}, // negative zero
|
||||
{"0s", 400}, // zero
|
||||
{"1ns", 400}, // positive but below the 1-minute floor
|
||||
{"59s", 400}, // just under the floor
|
||||
{"1m", 201}, // exactly the floor
|
||||
{"90s", 201}, // just over the floor
|
||||
{"8760h", 201}, // exactly 1 year
|
||||
{"8785h", 400}, // 1 year + 1 day: over the cap
|
||||
{"87600h", 400}, // 10 years, the originally reported case
|
||||
}
|
||||
for _, c := range cases {
|
||||
globalLimiter = newLimiter() // avoid create rate limit between cases
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
"expires_in": c.expiresIn,
|
||||
}, "files", "pic.txt", "file data")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != c.wantCode {
|
||||
t.Errorf("expires_in %q: got %d want %d (%s)",
|
||||
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
// Regression tests for #63: DELETE /api/pastes/{id} must require the
|
||||
// deletion token (Authorization header or ?token= query param, constant-time
|
||||
// compare). Without a token, or with a wrong token, the paste must survive
|
||||
// and the response must be 403.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createTestPaste creates a paste via the API and returns id + deletion token.
|
||||
func createTestPaste(t *testing.T, h http.Handler) (string, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"delete me"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
DeletionToken string `json:"deletion_token"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
if created.ID == "" || created.DeletionToken == "" {
|
||||
t.Fatalf("create response missing id/deletion_token: %s", rec.Body.String())
|
||||
}
|
||||
return created.ID, created.DeletionToken
|
||||
}
|
||||
|
||||
func pasteExists(t *testing.T, h http.Handler, id string) bool {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == 200 {
|
||||
return true
|
||||
}
|
||||
if rec.Code == 404 {
|
||||
return false
|
||||
}
|
||||
t.Fatalf("get after delete: got %d", rec.Code)
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDeleteWithoutTokenForbidden(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id, _ := createTestPaste(t, h)
|
||||
|
||||
rec := doReq(t, h, "DELETE", "/api/pastes/"+id, "", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete without token: got %d want 403", rec.Code)
|
||||
}
|
||||
if !pasteExists(t, h, id) {
|
||||
t.Fatal("paste was deleted without a token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithWrongTokenForbidden(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id, _ := createTestPaste(t, h)
|
||||
|
||||
// query param
|
||||
rec := doReq(t, h, "DELETE", "/api/pastes/"+id+"?token=wrong-token", "", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete with wrong token (query): got %d want 403", rec.Code)
|
||||
}
|
||||
// header
|
||||
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete with wrong token (header): got %d want 403", rec.Code)
|
||||
}
|
||||
if !pasteExists(t, h, id) {
|
||||
t.Fatal("paste was deleted with a wrong token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithCorrectToken(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id, tok := createTestPaste(t, h)
|
||||
|
||||
// via Authorization header
|
||||
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("delete with correct token (header): got %d want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if pasteExists(t, h, id) {
|
||||
t.Fatal("paste still exists after authorized delete")
|
||||
}
|
||||
|
||||
// via query param
|
||||
id, tok = createTestPaste(t, h)
|
||||
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"?token="+tok, "", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("delete with correct token (query): got %d want 200", rec.Code)
|
||||
}
|
||||
if pasteExists(t, h, id) {
|
||||
t.Fatal("paste still exists after authorized delete (query)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteByCreatorViewerCookieStillAllowed(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
// create from a specific browser
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"mine"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator-abc"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
// creator browser deletes without a token: allowed (#37 /mine delete button)
|
||||
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, "creator-abc", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("creator delete: got %d want 200", rec.Code)
|
||||
}
|
||||
|
||||
// a different browser is still forbidden
|
||||
id, _ := createTestPaste(t, h)
|
||||
rec = doReq(t, h, "DELETE", "/api/pastes/"+id, "someone-else", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("other browser delete: got %d want 403", rec.Code)
|
||||
}
|
||||
if !pasteExists(t, h, id) {
|
||||
t.Fatal("paste deleted by unrelated browser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletionAuthorizationExtract(t *testing.T) {
|
||||
mk := func(hdr, q string) *http.Request {
|
||||
req := httptest.NewRequest("DELETE", "/api/pastes/x"+q, nil)
|
||||
if hdr != "" {
|
||||
req.Header.Set("Authorization", hdr)
|
||||
}
|
||||
return req
|
||||
}
|
||||
cases := []struct {
|
||||
hdr, q, want string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"Bearer tok", "", "tok"},
|
||||
{"bearer tok", "", "tok"},
|
||||
{"Token tok", "", "tok"},
|
||||
{"tok", "", "tok"},
|
||||
{"", "?token=q", "q"},
|
||||
{"Bearer hdr", "?token=q", "hdr"}, // header wins
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := deletionAuthorization(mk(c.hdr, c.q)); got != c.want {
|
||||
t.Errorf("deletionAuthorization(hdr=%q q=%q) = %q want %q", c.hdr, c.q, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package api
|
||||
|
||||
// Regression tests for #68 input-validation gaps: negative/oversized content
|
||||
// lengths (413), limit=0 → default page size, unchecked query params
|
||||
// (negative offset), and negative burn_after_reads.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createPasteRaw(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// A request whose decoded content exceeds the admin-tunable cap is rejected
|
||||
// with 413 and a clear message (content under the body cap, over the
|
||||
// content cap).
|
||||
func TestCreatePasteContentOverCap413(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
// content over MaxContentBytes (5MiB) but under body cap (+4KiB): send
|
||||
// just over the content cap so the per-field check fires first.
|
||||
content := strings.Repeat("a", 5*1024*1024+10)
|
||||
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s"}`, content))
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "content exceeds max") {
|
||||
t.Fatalf("unclear error message: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A request whose entire body is larger than the server-side body cap is cut
|
||||
// off by http.MaxBytesReader and answered with 413, not decoded into memory
|
||||
// (#68: previously a giant body was fully buffered, then rejected only at
|
||||
// the per-field check — actually the decode happened before any check).
|
||||
func TestCreatePasteBodyOverCap413(t *testing.T) {
|
||||
s := testServer(t)
|
||||
// shrink the content cap so the body cap is small too
|
||||
ss := s.settings.get()
|
||||
ss.MaxContentBytes = 64 * 1024
|
||||
if err := s.settings.set(ss); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := s.routes()
|
||||
|
||||
content := strings.Repeat("a", 200*1024) // 200KiB > 64KiB+4KiB body cap
|
||||
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s","title":"x"}`, content))
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Negative content lengths cannot be expressed via JSON, but a negative
|
||||
// expires-style numeric payload must not crash; more importantly the
|
||||
// burn_after_reads field: negative values are rejected with a clear message.
|
||||
func TestCreatePasteNegativeBurnAfterReads(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
rec := createPasteRaw(t, h, `{"content":"hi","burn_after_reads":-5}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "burn_after_reads") {
|
||||
t.Fatalf("unclear error message: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 0 and positive values still work (0 = default single read, per #49)
|
||||
rec = createPasteRaw(t, h, `{"content":"hi","burn_after_reads":0,"burn_after_read":true}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("zero burn_after_reads: got %d want 201: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// limit=0 on list endpoints returns the default page size (existing clamp
|
||||
// treats <=0 as default; #68 asks this be explicit and tested).
|
||||
func TestListLimitZeroUsesDefault(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
// seed 3 public pastes
|
||||
for i := 0; i < 3; i++ {
|
||||
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("seed: got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
for _, q := range []string{"/api/public?limit=0", "/api/public"} {
|
||||
req := httptest.NewRequest("GET", q, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("%s: got %d", q, rec.Code)
|
||||
}
|
||||
var got struct {
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
Items []struct{ ID string } `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Limit != 25 || len(got.Items) != 3 {
|
||||
t.Fatalf("%s: limit=%d items=%d, want limit 25 and all 3 items", q, got.Limit, len(got.Items))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Huge limit values are clamped to the max page size (already the behavior;
|
||||
// regression-tested here per #68 "validate unchecked params").
|
||||
func TestListLimitHugeClamped(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/public?limit=999999999", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var got struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Limit != 25 {
|
||||
t.Fatalf("limit=%d, want clamped to 25", got.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
// Negative offset previously passed through unchecked to SQL (harmless in
|
||||
// SQLite, but invalid); it must be clamped to 0 (#68).
|
||||
func TestListNegativeOffsetClamped(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("seed: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/public?offset=-999", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
var got struct {
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Offset != 0 || got.Total != 2 {
|
||||
t.Fatalf("offset=%d total=%d, want offset 0 and total 2", got.Offset, got.Total)
|
||||
}
|
||||
|
||||
// /api/mine too (needs the viewer cookie)
|
||||
req = httptest.NewRequest("GET", "/api/mine?offset=-5", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: "offclamp"})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var mine struct {
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &mine)
|
||||
if mine.Offset != 0 {
|
||||
t.Fatalf("mine offset=%d, want 0", mine.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-numeric limit/offset fall back to defaults instead of 500s.
|
||||
func TestListGarbageParams(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/public?limit=abc&offset=xyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
var got struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Limit != 25 || got.Offset != 0 {
|
||||
t.Fatalf("limit=%d offset=%d, want 25/0", got.Limit, got.Offset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package api
|
||||
|
||||
// #81: ALL password verification attempts (GET query param, header, POST
|
||||
// form) must go through the per-IP unlock limiter. Regression: N wrong
|
||||
// passwords via GET ?password= must eventually yield 429.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createPasswordPaste(t *testing.T, s *apiServer, pw string) string {
|
||||
t.Helper()
|
||||
h := s.routes()
|
||||
body := `{"content":"secret","password":"` + pw + `"}`
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
return created.ID
|
||||
}
|
||||
|
||||
// TestRateLimitGetPasswordQuery: repeated wrong passwords via GET
|
||||
// ?password= must eventually return 429 (unlock limiter: burst 5).
|
||||
func TestRateLimitGetPasswordQuery(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id := createPasswordPaste(t, s, "hunter2")
|
||||
|
||||
var saw429 bool
|
||||
// more attempts than the unlock burst (5)
|
||||
for i := 0; i < 10; i++ {
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=wrong"+string(rune('a'+i)), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == 429 {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after repeated wrong ?password= attempts, never got one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitGetPasswordHeader: same guarantee for the X-Paste-Password header path.
|
||||
func TestRateLimitGetPasswordHeader(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id := createPasswordPaste(t, s, "hunter2")
|
||||
|
||||
var saw429 bool
|
||||
for i := 0; i < 10; i++ {
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == 429 {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after repeated wrong header password attempts, never got one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitGetPasswordCorrectStillAllowed: a correct password must still
|
||||
// work within the burst (the limiter gates attempts, not correctness).
|
||||
func TestRateLimitGetPasswordCorrectStillAllowed(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id := createPasswordPaste(t, s, "hunter2")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=hunter2", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected 200 for correct password within burst, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -30,7 +42,9 @@ func TestCreateAndGetPaste(t *testing.T) {
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created struct{ ID string `json:"id"` }
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
if len(created.ID) != 6 {
|
||||
t.Fatalf("unexpected id: %q", created.ID)
|
||||
@@ -61,7 +75,9 @@ func TestPasswordProtection(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
// without password -> 401
|
||||
@@ -121,10 +137,14 @@ func TestSoftDelete(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
DeletionToken string `json:"deletion_token"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+created.DeletionToken)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
@@ -163,6 +183,46 @@ func TestListPublicExcludesUnlisted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPublicExcludesPasswordAndUnlisted(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
bodies := []string{
|
||||
`{"content":"open","visibility":"public"}`,
|
||||
`{"content":"locked","visibility":"public","password":"hunter2"}`,
|
||||
`{"content":"hidden","visibility":"unlisted"}`,
|
||||
}
|
||||
for _, body := range bodies {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create %s: got %d", body, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/public", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("list public: got %d", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Total int `json:"total"`
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Total != 1 || len(resp.Items) != 1 {
|
||||
t.Fatalf("expected only the 1 public paste, got total=%d items=%d", resp.Total, len(resp.Items))
|
||||
}
|
||||
// password-protected and unlisted pastes must not appear (no metadata leak)
|
||||
for _, secret := range []string{"hunter2", "locked", "hidden"} {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Fatalf("leaked %q in /api/public response", secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
@@ -170,19 +230,21 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
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 +252,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)
|
||||
}
|
||||
}
|
||||
@@ -206,7 +268,9 @@ func TestRawEndpoint(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
req = httptest.NewRequest("GET", "/raw/"+created.ID, nil)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
// #83 regression tests: `public` boolean in the create payload must map to
|
||||
// visibility (false -> unlisted, true -> public); string `visibility` still works.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createPasteBody(t *testing.T, h *apiServer, body string) map[string]any {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad json: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func getVis(t *testing.T, h *apiServer, id string) string {
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("get %s: got %d", id, rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad json: %v", err)
|
||||
}
|
||||
return resp.Visibility
|
||||
}
|
||||
|
||||
// TestPublicBooleanFalseMapsToUnlisted: {"public": false} must create an unlisted paste.
|
||||
func TestPublicBooleanFalseMapsToUnlisted(t *testing.T) {
|
||||
s := testServer(t)
|
||||
resp := createPasteBody(t, s, `{"content":"x","public":false}`)
|
||||
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
|
||||
t.Fatalf("public:false -> got visibility %q, want unlisted", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicBooleanTrueMapsToPublic: {"public": true} must create a public paste.
|
||||
func TestPublicBooleanTrueMapsToPublic(t *testing.T) {
|
||||
s := testServer(t)
|
||||
resp := createPasteBody(t, s, `{"content":"x","public":true}`)
|
||||
if v := getVis(t, s, resp["id"].(string)); v != "public" {
|
||||
t.Fatalf("public:true -> got visibility %q, want public", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicBooleanOverridesString: boolean wins when both fields are sent.
|
||||
func TestPublicBooleanOverridesString(t *testing.T) {
|
||||
s := testServer(t)
|
||||
resp := createPasteBody(t, s, `{"content":"x","visibility":"public","public":false}`)
|
||||
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
|
||||
t.Fatalf("boolean override -> got %q, want unlisted", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibilityStringStillWorks: existing string contract unchanged.
|
||||
func TestVisibilityStringStillWorks(t *testing.T) {
|
||||
s := testServer(t)
|
||||
resp := createPasteBody(t, s, `{"content":"x","visibility":"unlisted"}`)
|
||||
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
|
||||
t.Fatalf("string field -> got %q, want unlisted", v)
|
||||
}
|
||||
resp = createPasteBody(t, s, `{"content":"y","visibility":"public"}`)
|
||||
if v := getVis(t, s, resp["id"].(string)); v != "public" {
|
||||
t.Fatalf("string field -> got %q, want public", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicListExcludesPublicFalse: {"public":false} pastes stay out of /api/public.
|
||||
func TestPublicListExcludesPublicFalse(t *testing.T) {
|
||||
s := testServer(t)
|
||||
createPasteBody(t, s, `{"content":"hidden","public":false}`)
|
||||
req := httptest.NewRequest("GET", "/api/public", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.routes().ServeHTTP(rec, req)
|
||||
var resp struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Total != 0 {
|
||||
t.Fatalf("public:false paste leaked into /api/public: total=%d", resp.Total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 client IP for rate-limit keying (#85).
|
||||
//
|
||||
// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
|
||||
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
|
||||
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
|
||||
// proxy observed and is unspoofable by the client (a client-supplied fake
|
||||
// entry only lands on the LEFT and is ignored). This matches chi's
|
||||
// middleware.RealIP semantics for a single trusted proxy hop.
|
||||
//
|
||||
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
|
||||
// reachable deployments must NOT expose the app to untrusted networks
|
||||
// without a proxy in front, or attackers could forge the rightmost entry.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.LastIndex(xff, ","); i >= 0 {
|
||||
return strings.TrimSpace(xff[i+1:])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
|
||||
return strings.TrimSpace(xr)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// rateLimitAdmin: 5 attempts per minute per IP on the admin key check (#66),
|
||||
// same pattern as the unlock limiter (#34).
|
||||
func rateLimitAdmin(r *http.Request) bool {
|
||||
return globalLimiter.allow("admin:"+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))
|
||||
}
|
||||
@@ -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">"hello"</span>`)) {
|
||||
t.Fatalf("no string span: %s", out)
|
||||
}
|
||||
// unsupported language returns escaped plain text
|
||||
plain := lang.HighlightCode("<b>x</b>", "text")
|
||||
if plain != "<b>x</b>" {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package api
|
||||
|
||||
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
|
||||
// (appended by the trusted Traefik proxy), never the raw/leftmost header
|
||||
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
|
||||
// limit or rotate buckets.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientIPTakesRightmostXFF(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
|
||||
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
|
||||
if got := clientIP(r); got != "203.0.113.9" {
|
||||
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPXRealIPFallback(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.RemoteAddr = "10.42.0.7:51000"
|
||||
r.Header.Set("X-Real-Ip", "203.0.113.10")
|
||||
if got := clientIP(r); got != "203.0.113.10" {
|
||||
t.Fatalf("clientIP = %q, want 203.0.113.10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPDirectFallback(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.RemoteAddr = "198.51.100.5:51000"
|
||||
if got := clientIP(r); got != "198.51.100.5" {
|
||||
t.Fatalf("clientIP = %q, want 198.51.100.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
|
||||
// leftmost XFF entry stays limited on their real (rightmost) IP.
|
||||
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
h := srv.routes()
|
||||
for i := 0; i < 5; i++ {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||
req.RemoteAddr = "10.42.0.7:51000"
|
||||
// each request spoofs a DIFFERENT leftmost entry
|
||||
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != 201 {
|
||||
t.Fatalf("req %d: want 201, got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
// 6th request, still the same real IP, new spoofed prefix: must 429
|
||||
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||
req.RemoteAddr = "10.42.0.7:51000"
|
||||
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != 429 {
|
||||
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func spoofN(i int) string {
|
||||
return "1.2.3." + string(rune('0'+i))
|
||||
}
|
||||
|
||||
// Distinct real IPs must still get distinct buckets (no over-limiting).
|
||||
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
h := srv.routes()
|
||||
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
|
||||
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||
req.RemoteAddr = "10.42.0.7:51000"
|
||||
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != 201 {
|
||||
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// 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(a.limitRequestBody) // #68: hard server-side body cap -> 413
|
||||
r.Use(viewerCookieMiddleware)
|
||||
r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
|
||||
|
||||
// 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 {
|
||||
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
|
||||
writeBodyTooLarge(w)
|
||||
return
|
||||
}
|
||||
writeErr(w, 400, "invalid json body")
|
||||
return
|
||||
}
|
||||
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
|
||||
writeErr(w, status, msg)
|
||||
return
|
||||
}
|
||||
if p.BurnAfterReads != nil { // #68: reject negative read budgets
|
||||
if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
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 {
|
||||
// #81: every password verification (header, query param, or empty)
|
||||
// goes through the same per-IP+paste unlock limiter as the POST form
|
||||
// path, so brute-force via GET ?password= or X-Paste-Password gets 429.
|
||||
if !rateLimitUnlock(row.ID, r) {
|
||||
writeRateLimited(w, 60)
|
||||
return
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// #58: only the reader that wins the atomic burn claim may see content.
|
||||
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
if !admitted {
|
||||
writeErr(w, 404, "paste not found")
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
// #63: deletion requires authorization. Either the deletion token issued
|
||||
// at create time (Authorization header or ?token= query param, matching
|
||||
// the create response's "deletion_token" field), or the creator browser
|
||||
// itself (client-sent vwr cookie matching the paste's viewer, #37).
|
||||
if !a.deletionAuthorized(r, row) {
|
||||
writeErr(w, 403, "deletion token required")
|
||||
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"})
|
||||
}
|
||||
|
||||
// deletionAuthorization extracts the deletion token from the request: the
|
||||
// Authorization header ("Bearer <t>", "Token <t>", or a bare token) or the
|
||||
// token query parameter. Returns "" when absent.
|
||||
func deletionAuthorization(r *http.Request) string {
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
for _, prefix := range []string{"Bearer ", "Token "} {
|
||||
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
return r.URL.Query().Get("token")
|
||||
}
|
||||
|
||||
// deletionAuthorized reports whether the request may soft-delete the paste:
|
||||
// a valid constant-time-matched deletion token, or the creator browser's
|
||||
// viewer cookie (#37). Plain API clients with no token get false.
|
||||
func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) bool {
|
||||
if tok := deletionAuthorization(r); tok != "" {
|
||||
return row.DeletionToken.Valid && row.DeletionToken.String != "" &&
|
||||
store.DeletionTokenEqual(row.DeletionToken.String, tok)
|
||||
}
|
||||
// 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)
|
||||
return vid != "" && viewerSentCookie(r) && row.ViewerID.Valid &&
|
||||
row.ViewerID.String != "" && row.ViewerID.String == vid
|
||||
}
|
||||
|
||||
// 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 := parseLimit(r, 50, 100)
|
||||
offset := parseOffset(r)
|
||||
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 := parseLimit(r, 25, 100)
|
||||
offset := parseOffset(r)
|
||||
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. #58: a reader that loses
|
||||
// the burn claim must not receive the content.
|
||||
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
if !admitted {
|
||||
http.Error(w, "not found", 404)
|
||||
return
|
||||
}
|
||||
// #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("&", "&", "<", "<", ">", ">")
|
||||
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) }
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// #68 input-validation helpers. Paste/can payloads are size-capped and list
|
||||
// endpoints get a single place where limit/offset are parsed and clamped.
|
||||
|
||||
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
|
||||
// admin-tunable content cap plus headroom for JSON field overhead, floored
|
||||
// at 64KiB so a tiny admin-configured cap can't break small requests.
|
||||
func (a *apiServer) maxRequestBody() int64 {
|
||||
s := a.settings.get()
|
||||
max := s.MaxContentBytes + 4096
|
||||
if max < 64*1024 {
|
||||
max = 64 * 1024
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// limitRequestBody wraps the request body with http.MaxBytesReader so
|
||||
// oversized payloads are cut off server-side instead of being fully decoded
|
||||
// into memory before the per-field size check runs (#68). A read over the
|
||||
// cap surfaces as *http.MaxBytesError, which handlers map to 413.
|
||||
func (a *apiServer) limitRequestBody(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.maxRequestBody())
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// writeBodyTooLarge emits the 413 response for a body rejected by
|
||||
// MaxBytesReader.
|
||||
func writeBodyTooLarge(w http.ResponseWriter) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
||||
}
|
||||
|
||||
// isBodyTooLarge reports whether err came from http.MaxBytesReader.
|
||||
func isBodyTooLarge(err error) bool {
|
||||
var mbe *http.MaxBytesError
|
||||
return errors.As(err, &mbe)
|
||||
}
|
||||
|
||||
// checkContent validates paste content: rejects whitespace-only content
|
||||
// (400) and content over the byte cap (413). Returns (0, "") when valid.
|
||||
func checkContent(content string, maxBytes int64) (int, string) {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return http.StatusBadRequest, "content is required"
|
||||
}
|
||||
if int64(len(content)) > maxBytes { // #40/#68: admin-tunable cap
|
||||
return http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("content exceeds max %d bytes", maxBytes)
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// parseLimit clamps the ?limit query param: missing/non-numeric/zero/negative
|
||||
// or over-max values fall back to def. Zero intentionally maps to the default
|
||||
// page size, matching the pre-existing `<= 0` clamp (#68).
|
||||
func parseLimit(r *http.Request, def, max int) int {
|
||||
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil || n <= 0 || n > max {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseOffset clamps the ?offset query param: missing/non-numeric or negative
|
||||
// values become 0 (#68: negative offsets previously passed through to SQL).
|
||||
func parseOffset(r *http.Request) int {
|
||||
n, err := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseBurnAfterReads validates the burn_after_reads field (#68): negative
|
||||
// values are rejected; zero/absent mean the default single read.
|
||||
func parseBurnAfterReads(n int) error {
|
||||
if n < 0 {
|
||||
return errors.New("burn_after_reads must be a positive number")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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.
|
||||
// #58: admission is atomic. Returns (remaining, admitted). admitted is true
|
||||
// only when this caller may serve the content: for legacy burn pastes the
|
||||
// caller wins exactly when its conditional soft delete flipped deleted_at
|
||||
// (RowsAffected), and for read-budget pastes the caller wins exactly when its
|
||||
// conditional UPDATE (reads_used < reads_limit) incremented the counter - so
|
||||
// concurrent readers can never both consume the last read. Losing callers
|
||||
// must treat the paste as gone. view_count is tracked separately and
|
||||
// unaffected.
|
||||
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, admitted bool) {
|
||||
if !row.ReadsLimit.Valid {
|
||||
if row.BurnAfterRead {
|
||||
// #58: atomic claim - only the caller whose UPDATE actually
|
||||
// flips deleted_at from NULL may serve the content.
|
||||
ok, err := s.SoftDelete(row.ID)
|
||||
r := 0
|
||||
if err != nil || !ok {
|
||||
return &r, false
|
||||
}
|
||||
return &r, true
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
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, true
|
||||
}
|
||||
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)
|
||||
// #58: conditional increment - only succeeds while budget remains, so
|
||||
// concurrent readers cannot both consume the final read.
|
||||
res, err := s.db.Exec(`UPDATE pastes SET reads_used = reads_used + 1
|
||||
WHERE id = ? AND deleted_at IS NULL AND reads_used < ?`, row.ID, row.ReadsLimit.Int64)
|
||||
if err != nil {
|
||||
r := 0
|
||||
return &r, false
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// Lost the race: budget exhausted (or paste already burned).
|
||||
r := 0
|
||||
return &r, false
|
||||
}
|
||||
var used int64
|
||||
s.db.QueryRow(`SELECT reads_used FROM pastes WHERE id = ?`, row.ID).Scan(&used)
|
||||
if used >= row.ReadsLimit.Int64 {
|
||||
// Atomic burn; either way the paste is gone for future readers.
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,490 @@
|
||||
// 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"`
|
||||
// #83: accept "public": true/false as an alias for visibility.
|
||||
Public *bool `json:"public,omitempty"`
|
||||
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
|
||||
}
|
||||
// #58: a single write connection. SQLite allows only one writer at a
|
||||
// time; with multiple pooled connections concurrent writes surface as
|
||||
// SQLITE_BUSY errors ("database is locked") instead of serializing, and
|
||||
// the burn-after-read race tests saw spurious 500s under parallel reads.
|
||||
db.SetMaxOpenConns(1)
|
||||
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
|
||||
// #83: "public": false -> unlisted, true -> public; overrides string field
|
||||
if p.Public != nil {
|
||||
if *p.Public {
|
||||
visibility = "public"
|
||||
} else {
|
||||
visibility = "unlisted"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ListPublic backs /api/public and the public listing page. Visibility rules
|
||||
// mirror the history page: only non-deleted, non-expired, non-can pastes are
|
||||
// listed, and password-protected pastes are excluded at the query level
|
||||
// (#65) so their metadata (title, slug, existence) never leaks.
|
||||
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 password_hash 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 password_hash 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
|
||||
}
|
||||
|
||||
// SoftDelete marks a paste deleted (burned) atomically (#58): the deleted_at
|
||||
// IS NULL guard means only the first caller flips the row. Returns true when
|
||||
// this call performed the delete (RowsAffected > 0), false when the paste was
|
||||
// already deleted - callers use this to decide read admission atomically.
|
||||
func (s *Store) SoftDelete(id string) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, 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
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// #59: SecurityHeaders must add the CSP and hardening headers to rendered
|
||||
// HTML responses only; JSON and /raw responses pass through untouched.
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
pages := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte("<html><body>ok</body></html>"))
|
||||
})
|
||||
h := SecurityHeaders(pages)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||
wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
|
||||
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
|
||||
t.Errorf("CSP = %q, want %q", got, wantCSP)
|
||||
}
|
||||
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
|
||||
t.Errorf("Referrer-Policy = %q, want no-referrer", got)
|
||||
}
|
||||
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
|
||||
// JSON/raw responses: headers are now set unconditionally BEFORE the handler
|
||||
// runs. The previous post-handler approach was silently dropped once a page
|
||||
// handler flushed its template output (headers must be set before WriteHeader).
|
||||
// CSP/nosniff/referrer on non-HTML bodies is harmless and desirable.
|
||||
jsonh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
rec = httptest.NewRecorder()
|
||||
jsonh.ServeHTTP(rec, httptest.NewRequest("GET", "/api/x", nil))
|
||||
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
|
||||
t.Errorf("CSP missing on JSON response: got %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
|
||||
t.Errorf("Referrer-Policy missing on JSON response: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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=' + (opts.fetchLimit || 100) + '&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 };
|
||||
})();
|
||||
@@ -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" .}}
|
||||
@@ -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}}
|
||||
@@ -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" .}}
|
||||
@@ -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}}
|
||||
@@ -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">×</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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -0,0 +1,318 @@
|
||||
// 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, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
|
||||
if !admitted { // #58: lost the burn claim; do not render content
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
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} }
|
||||
|
||||
// #59: security headers for rendered HTML pages. Applied wherever the
|
||||
// response is text/html (page templates and the inline can page); JSON API
|
||||
// responses and /raw content pass through untouched. script-src allows
|
||||
// 'unsafe-inline' because the page templates carry inline scripts; CSP
|
||||
// default-src 'self' still blocks external content and object/frame embeds,
|
||||
// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34
|
||||
// pentest. Runs after the handler so the Content-Type is already set.
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set before the handler runs: once a handler writes (template render
|
||||
// flushes), header mutations are silently dropped. Setting the headers
|
||||
// unconditionally is safe: CSP/nosniff/referrer on JSON or /raw bodies
|
||||
// is harmless and arguably desirable.
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">")
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
{{define "foot"}}{{end}}
|
||||
@@ -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" .}}
|
||||
@@ -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}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||