Remove cans feature; harden pw_/tok_ cookie flags (pre-release fixes) #307

Merged
fen merged 2 commits from palette/t_1cedb8a0-remove-cans-feature-harden-cookie-flags into dev 2026-10-16 21:59:34 +00:00
20 changed files with 176 additions and 1356 deletions
Showing only changes of commit e54fbc36ef - Show all commits

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
Palette - self-hosted pastebin with paste cans
Palette - self-hosted pastebin
Copyright (C) 2026 poslop
This program is free software: you can redistribute it and/or modify
+2 -2
View File
@@ -10,7 +10,7 @@ a web UI for sharing text and small files.
## Features
- Text pastes and cans (multiple items in one share)
- Text pastes
- File attachments (one file per paste, up to 25 MB)
- Password protected pastes
- Expire after a specified time
@@ -66,7 +66,7 @@ go build -o palette ./cmd/palette
| `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 / file attachment size in bytes (25 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max file attachment / multipart size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
+2 -2
View File
@@ -32,8 +32,8 @@ services:
# Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can) or a
# paste file attachment.
# Max size in bytes for multipart request parsing (paste file
# attachments are capped at 25 MB).
# Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400"
+5 -53
View File
@@ -18,11 +18,11 @@ The examples below use `http://localhost:8080` as a placeholder.
There is no user account system. Access control works through three mechanisms:
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste or can, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
- Deletion token. Creating a paste returns a one-time `deletion_token`. Sending it in the `Authorization` header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
- Admin key. The admin settings endpoints require an install-time key sent in the `X-Admin-Key` header. The key comes from `PALETTE_ADMIN_KEY` or is generated and persisted server-side at first start.
Password-protected pastes and cans are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
Password-protected pastes are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
### Rate limiting
@@ -41,7 +41,6 @@ Exceeding a limit returns `429` with a `Retry-After` header and the error code `
- Paste content: `PALETTE_MAX_TEXT` (default 5 MB), admin tunable. Larger content is rejected with `413`.
- Attached files: 25 MB per file, one file per paste.
- Can items: `PALETTE_MAX_ITEM` (default 25 MB) per item.
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
### Error format
@@ -130,13 +129,13 @@ Errors: `400` token required, `403` invalid token, `404` not found.
#### GET /api/mine
List pastes (and cans) created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
List pastes created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`, `is_can`.
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`.
#### GET /api/public
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `is_can` and `custom_slug`.
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `custom_slug`.
### Files and raw content
@@ -162,53 +161,6 @@ Response `200`: `{"language": "go"}`. The result is a best-effort guess and may
Errors: `400` invalid JSON body, `429` rate limited.
### Cans
A can bundles multiple text items and files into one shareable page at `/can/{id}`. Cans appear in `/api/mine` and `/api/public` with `is_can: true`.
#### POST /api/pastes/can
Create a can. Multipart form only. Fields:
| Field | Type | Notes |
|---|---|---|
| `title` | string | optional |
| `description` | string | optional |
| `visibility` | string | `public` or `unlisted`; default `public` |
| `expires_in` | string | Go duration, 1 minute to 1 year |
| `password` | string | optional |
| `custom_slug` | string | optional; becomes the can id |
| `json_items` | string | JSON array of `{"title": "...", "content": "...", "language": "..."}` text items |
| files | parts | one or more uploaded file parts |
A can needs at least one item (file or `json_items`); otherwise the can is deleted and `400` (`content_empty`) is returned.
Response `201`: `{"id": "...", "url": "/can/{id}", "items": n}`.
Errors: `400` (`invalid_visibility`, `expiry_invalid`, `invalid json_items`, `content_empty`), `409` slug errors (codes `slug_taken`, `slug_invalid`, `slug_reserved`), `413` item over the size cap.
#### GET /api/cans/{id}
Can metadata with an item list. Password-protected cans require `X-Paste-Password`.
Response `200`: `id`, `title`, `visibility`, `created_at`, and `items`, where each item has `id`, `title`, `content_type`, `size`, and `url` (the item's fetch URL).
Errors: `404` not found or expired, `401` password required.
#### GET /api/cans/{id}/items/{item}
Fetch one can item's content. Items inherit the can's password: send `X-Paste-Password`, or the browser's `pw_{can}` unlock cookie. Served with the same safe content-type guard as `/raw`.
Errors: `404` item not found or not a can item, `401` password required.
#### DELETE /api/cans/{id}
Soft delete a can. Creator browser only: the `vwr` cookie must match the can's viewer. Cans carry no deletion token.
Response `200`: `{"status": "soft-deleted"}`.
Errors: `403` deletion not authorized, `404` not found.
### Admin
#### GET /admin/api/settings and POST /admin/api/settings
-273
View File
@@ -1,273 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"palette/internal/store"
"strings"
"time"
"github.com/go-chi/chi/v5"
"palette/internal/web"
)
// CreateCan makes a can with N items (multipart form).
// Fields: title, description, visibility, expires_in, password, files (one or more), or json_items for text items.
func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(a.cfg.MaxItemBytes); err != nil {
writeErr(w, 400, "multipart form required")
return
}
title := r.FormValue("title")
visibility := r.FormValue("visibility")
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
writeErrCode(w, 400, "invalid_visibility", "visibility must be public or unlisted")
return
}
expiresIn := r.FormValue("expires_in")
password := r.FormValue("password")
customSlug := r.FormValue("custom_slug")
var expiresAt *int64
now := time.Now().Unix()
if expiresIn != "" {
d, err := time.ParseDuration(expiresIn)
if err != nil {
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
return
}
// #60/#48: clamp at the API boundary like the pastes API does -
// reject zero/negative and durations past the 1-year UI cap.
if !store.ValidExpiry(d) {
writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year")
return
}
t := now + int64(d.Seconds())
expiresAt = &t
}
var pwHash *string
if password != "" {
h, err := store.Argon2IDHash(password)
if err != nil {
writeErr(w, 500, "hash error")
return
}
pwHash = &h
}
canID := store.GenSlug(8)
var slugPtr *string
if customSlug != "" {
slugPtr = &customSlug
}
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
if err != nil {
switch err {
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
writeErrCode(w, 409, createErrCode(err), err.Error())
default:
writeErr(w, 500, "db error")
}
return
}
if slugPtr != nil {
canID = customSlug // #4: custom slug becomes the can id
}
// #4: remember the creating browser so /mine and viewer-scoped delete work
a.store.Exec(`UPDATE paste_cans SET viewer_id=? WHERE id=?`, currentViewerID(r), canID)
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
itemCount := 0
if itemsJSON := r.FormValue("json_items"); itemsJSON != "" {
var items []map[string]string
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
writeErr(w, 400, "invalid json_items")
return
}
for _, it := range items {
content := it["content"]
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("item %q exceeds max", it["title"]))
return
}
lang := it["language"]
if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
// uploaded files
if r.MultipartForm != nil {
for _, headers := range r.MultipartForm.File {
for _, fh := range headers {
f, err := fh.Open()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
content, err := io.ReadAll(f)
f.Close()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("file %q exceeds max %d bytes", fh.Filename, a.cfg.MaxItemBytes))
return
}
contentStr := string(content)
if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
}
if itemCount == 0 {
a.store.DeleteCan(canID)
writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)")
return
}
writeJSON(w, 201, map[string]any{
"id": canID, "url": "/can/" + canID, "items": itemCount,
})
}
// handleDeleteCan soft-deletes a can (parity with paste deletion, #63):
// requires the vwr viewer cookie matching the can's viewer (cans carry no
// deletion token since they are built in the browser).
func (a *apiServer) handleDeleteCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
writeErr(w, 404, "can not found")
return
}
vid := currentViewerID(r)
if !(vid != "" && viewerSentCookie(r) && can.ViewerID.Valid &&
can.ViewerID.String != "" && can.ViewerID.String == vid) {
writeErr(w, 403, "deletion not authorized")
return
}
if _, err := a.store.SoftDeleteCan(can.ID); err != nil {
writeErr(w, 500, "db error")
return
}
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
func detectContentType(name string, content []byte) string {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".png"):
return "image/png"
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lower, ".gif"):
return "image/gif"
case strings.HasSuffix(lower, ".webp"):
return "image/webp"
case strings.HasSuffix(lower, ".pdf"):
return "application/pdf"
}
if len(content) > 8 && content[0] == 0x89 && content[1] == 'P' {
return "image/png"
}
return "text/plain"
}
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil {
writeErr(w, 500, "db error")
return
}
if can == nil {
writeErr(w, 404, "can not found")
return
}
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
writeErr(w, 404, "can expired")
return
}
if can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
items, err := a.store.ListCanItems(can.ID)
if err != nil {
writeErr(w, 500, "db error")
return
}
type itemMeta struct {
ID string `json:"id"`
Title *string `json:"title"`
ContentType string `json:"content_type"`
Size int `json:"size"`
URL string `json:"url"`
}
metas := make([]itemMeta, 0, len(items))
for _, it := range items {
metas = append(metas, itemMeta{
ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
})
}
writeJSON(w, 200, map[string]any{
"id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
"created_at": can.CreatedAt, "items": metas,
})
}
func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "item")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "item not found")
return
}
// must belong to a can
if !row.CanID.Valid {
writeErr(w, 404, "not a can item")
return
}
// inherit can password protection: password via header/query, or the
// same pw_<can> unlock cookie the can page sets (#4 cookie parity).
can, _ := a.store.GetCan(row.CanID.String)
if can != nil && can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
// fall back to the browser's unlock cookie for this can
c, cerr := r.Cookie("pw_" + can.ID)
if cerr != nil || c.Value != web.UnlockToken(can.ID) {
writeErr(w, 401, "password required")
return
}
}
}
// #34: same content-type guard as /raw — never serve active content types.
ct := row.ContentType
if !safeRawContentType(ct) {
ct = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write([]byte(row.Content))
}
-45
View File
@@ -1,45 +0,0 @@
package api
import (
"net/http/httptest"
"testing"
)
// #60: the cans API must clamp expires_in at the boundary exactly like the
// pastes API — reject zero/negative durations and anything over the 1-year
// UI cap, accept the exact boundaries.
func TestCreateCanExpiryBounds(t *testing.T) {
s := testServer(t)
h := s.routes()
cases := []struct {
expiresIn string
wantCode int
}{
{"-1h", 400}, // negative
{"-0s", 400}, // negative zero
{"0s", 400}, // zero
{"1ns", 400}, // positive but below the 1-minute floor
{"59s", 400}, // just under the floor
{"1m", 201}, // exactly the floor
{"90s", 201}, // just over the floor
{"8760h", 201}, // exactly 1 year
{"8785h", 400}, // 1 year + 1 day: over the cap
{"87600h", 400}, // 10 years, the originally reported case
}
for _, c := range cases {
globalLimiter = newLimiter() // avoid create rate limit between cases
body, ct := multipartBody(t, map[string]string{
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
"expires_in": c.expiresIn,
}, "files", "pic.txt", "file data")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.wantCode {
t.Errorf("expires_in %q: got %d want %d (%s)",
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
}
}
}
-414
View File
@@ -1,414 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"palette/internal/store"
)
// #4: cans appear in /api/public as normal rows with is_can=true.
func TestCanInPublicListing(t *testing.T) {
s := testServer(t)
h := s.routes()
// a can and a regular paste
body, ct := multipartBody(t, map[string]string{
"title": "Listed can",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"plain"}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("public: %d", rec.Code)
}
var got struct {
Total int `json:"total"`
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
Title any `json:"title"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Total != 2 {
t.Fatalf("expected total 2 (can + paste), got %d", got.Total)
}
foundCan := false
for _, it := range got.Items {
if it.ID == created.ID {
if !it.IsCan {
t.Fatalf("can row missing is_can flag")
}
foundCan = true
} else if it.IsCan {
t.Fatalf("plain paste flagged as can")
}
}
if !foundCan {
t.Fatalf("can not present in /api/public")
}
// #4: unlisted can must not be listed
body2, ct2 := multipartBody(t, map[string]string{
"title": "Hidden can",
"visibility": "unlisted",
"json_items": `[{"title":"b.txt","content":"BBB"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body2)
req.Header.Set("Content-Type", ct2)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("unlisted can create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Hidden can") {
t.Fatalf("unlisted can leaked into /api/public")
}
}
// #4: custom slug support for cans + conflict with existing paste slug.
func TestCanCustomSlug(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Slugged",
"custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can with slug: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID, URL string }
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID != "my-bundle" {
t.Fatalf("expected id my-bundle, got %q", created.ID)
}
// custom slug resolves on the can page route
req = httptest.NewRequest("GET", "/can/my-bundle", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Slugged") {
t.Fatalf("can page by slug: %d", rec.Code)
}
// duplicate slug rejected 409
body, ct = multipartBody(t, map[string]string{
"title": "Again", "custom_slug": "my-bundle",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("duplicate slug expected 409, got %d", rec.Code)
}
// reserved slug rejected
body, ct = multipartBody(t, map[string]string{
"title": "R", "custom_slug": "admin",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req = httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 409 {
t.Fatalf("reserved slug expected 409, got %d", rec.Code)
}
}
// #4: invalid expiry rejected on cans, mirroring paste behavior (#48).
func TestCanExpiryValidation(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Bad expiry", "expires_in": "30s",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400 for 30s expiry, got %d", rec.Code)
}
}
// #4: expired cans 404 on the page and API.
func TestCanExpiryAccess(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Dying", "expires_in": "1h",
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// backdate expiry to force the expired path
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, created.ID)
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can API expected 404, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expired can page expected 404, got %d", rec.Code)
}
}
// #4: password-protected can page uses the same unlock flow as pastes.
func TestCanUnlockFlow(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Locked can", "password": "pw123",
"json_items": `[{"title":"secret.txt","content":"sec content"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// GET page without cookie -> unlock form
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "locked") {
t.Fatalf("expected unlock form, got %d", rec.Code)
}
// POST wrong password -> unlock form with error
fd := strings.NewReader("password=wrong")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "Wrong password") {
t.Fatalf("expected wrong-password message")
}
// POST correct password -> page renders, cookie set
fd = strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("unlocked can page missing item content")
}
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("unlock cookie not set")
}
// GET with cookie -> unlocked
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("cookie unlock failed: %d", rec.Code)
}
// a forged cookie value must not unlock
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "sec content") {
t.Fatalf("forged cookie unlocked the can")
}
}
// #4: can item access inherits the can unlock cookie (not just query param).
func TestCanItemCookieParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Cookie can", "password": "pw123",
"json_items": `[{"title":"s.txt","content":"sec"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// unlock via page to get the cookie
fd := strings.NewReader("password=pw123")
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var pwCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pw_"+created.ID {
pwCookie = c
}
}
if pwCookie == nil {
t.Fatalf("no unlock cookie")
}
// item id from API (with password query)
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
itemID := can.Items[0].ID
// API item with the unlock cookie but no password -> 200
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(pwCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item via cookie expected 200, got %d", rec.Code)
}
// forged cookie -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("forged cookie on item expected 401, got %d", rec.Code)
}
}
// #4: viewer-scoped soft delete of cans.
func TestCanDeleteParity(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Doomed", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// delete without matching viewer -> 403
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 403 {
t.Fatalf("unauthorized delete expected 403, got %d", rec.Code)
}
// creator's browser -> 200
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("creator delete expected 200, got %d", rec.Code)
}
// gone from API and page
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("deleted can expected 404, got %d", rec.Code)
}
// gone from listings
req = httptest.NewRequest("GET", "/api/public", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "Doomed") {
t.Fatalf("deleted can still listed")
}
}
// #4: cans appear in /api/mine for the creating browser.
func TestCanInMine(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "Mine can", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/mine", nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var got struct {
Items []struct {
ID string `json:"id"`
IsCan bool `json:"is_can"`
} `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if len(got.Items) != 1 || !got.Items[0].IsCan {
t.Fatalf("can missing from /api/mine: %s", rec.Body.String())
}
}
// #4: sweep soft-deletes expired cans (parity with pastes).
func TestCanSweepExpired(t *testing.T) {
s := testServer(t)
s.store.CreateCan("sweepcan", "sweep", "", "public", nil, 1, nil, nil)
s.store.InsertCanItem("sweepcan", "a.txt", "AAA", "text/plain", nil, nil, nil, 1)
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, "sweepcan")
s.store.SweepExpired()
if can, _ := s.store.GetCan("sweepcan"); can != nil {
t.Fatalf("expired can survived sweep")
}
_ = store.GenSlug(4) // keep import if store pkg shrinks
}
-144
View File
@@ -1,144 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
)
func multipartBody(t *testing.T, fields map[string]string, fileField, fileName, fileContent string) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
w.WriteField(k, v)
}
if fileField != "" {
fw, _ := w.CreateFormFile(fileField, fileName)
fw.Write([]byte(fileContent))
}
w.Close()
return &buf, w.FormDataContentType()
}
func TestCreateAndGetCan(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "My can",
"json_items": `[{"title":"a.txt","content":"AAA"},{"title":"b.txt","content":"BBB"}]`,
"expires_in": "1h",
}, "files", "pic.txt", "file data")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can: got %d: %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
Items int `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.Items != 3 {
t.Fatalf("expected 3 items, got %d", created.Items)
}
// get can
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("get can: got %d", rec.Code)
}
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
if len(can.Items) != 3 {
t.Fatalf("expected 3 items in get, got %d", len(can.Items))
}
// fetch item
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+can.Items[0].ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item fetch: got %d", rec.Code)
}
}
func TestEmptyCanRejected(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{"title": "empty"}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400 for empty can, got %d", rec.Code)
}
}
func TestCanPasswordInheritedByItems(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "locked",
"password": "pw123",
"json_items": `[{"title":"s.txt","content":"sec"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// can without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("expected 401, got %d", rec.Code)
}
// get item id with pw
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
itemID := can.Items[0].ID
// item without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("item expected 401, got %d", rec.Code)
}
// item with pw -> 200
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item expected 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "sec") {
t.Fatalf("item content mismatch: %s", rec.Body.String())
}
}
+5 -2
View File
@@ -1,7 +1,7 @@
package api
// #143: create responses must set the short-lived tok_<id> HttpOnly cookie
// that the paste view reads for the one-time created banner.
// #143: create responses must set the short-lived tok_<id> HttpOnly+Secure
// cookie that the paste view reads for the one-time created banner.
import (
"net/http"
@@ -29,6 +29,9 @@ func TestCreateSetsDeletionTokenCookie(t *testing.T) {
if !c.HttpOnly {
t.Error("tok_ cookie not HttpOnly")
}
if !c.Secure {
t.Error("tok_ cookie not Secure")
}
if c.MaxAge != 60 {
t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge)
}
-35
View File
@@ -86,38 +86,3 @@ func TestErrorCodeRateLimited(t *testing.T) {
}
t.Fatalf("never got rate_limited, last code = %q", code)
}
func TestErrorCodeCanValidation(t *testing.T) {
h := testServer(t).routes()
post := func(fields map[string]string) *httptest.ResponseRecorder {
var b strings.Builder
for k, v := range fields {
b.WriteString("--B\r\nContent-Disposition: form-data; name=\"" + k + "\"\r\n\r\n" + v + "\r\n")
}
b.WriteString("--B--\r\n")
req := httptest.NewRequest("POST", "/api/pastes/can", strings.NewReader(b.String()))
req.Header.Set("Content-Type", "multipart/form-data; boundary=B")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// bad visibility
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "visibility": "nope"})); code != "invalid_visibility" {
t.Fatalf("visibility code = %q, want invalid_visibility", code)
}
// invalid expiry
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "expires_in": "1s"})); code != "expiry_invalid" {
t.Fatalf("expiry code = %q, want expiry_invalid", code)
}
// slug taken on can create
if rec := postCreate(h, `{"content":"a","custom_slug":"can-slug"}`); rec.Code != 201 {
t.Fatalf("seed create: %d", rec.Code)
}
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "custom_slug": "can-slug", "json_items": `[{"title":"a","content":"b"}]`})); code != "slug_taken" {
t.Fatalf("can slug code = %q, want slug_taken", code)
}
}
+4 -141
View File
@@ -1,5 +1,5 @@
// Package api implements palette's REST handlers and the HTTP router:
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
// pastes, guess-language, rate limiting middleware, and the admin API.
package api
import (
@@ -8,7 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
@@ -18,7 +17,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
langpkg "palette/internal/lang"
"palette/internal/store"
"palette/internal/web"
)
@@ -59,8 +57,7 @@ func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": msg, "code": code})
}
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
// code (#105).
// createErrCode maps a store.CreatePaste error to its UI error code (#105).
func createErrCode(err error) string {
switch {
case errors.Is(err, store.ErrSlugTaken):
@@ -103,16 +100,8 @@ func (a *apiServer) routes() http.Handler {
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.Delete("/cans/{id}", a.handleDeleteCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
})
// can page (#4): GET renders, POST unlocks (same flow as pastes)
r.Get("/can/{id}", a.handleCanPage)
r.Post("/can/{id}", a.handleCanPage)
// raw + #38 attachment serving
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
r.Get("/raw/{id}", a.handleRaw)
@@ -189,7 +178,7 @@ func viewerSentCookie(r *http.Request) bool {
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
http.SetCookie(w, &http.Cookie{
Name: "tok_" + pasteID, Value: token, Path: "/",
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
})
}
@@ -442,7 +431,6 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
"is_can": row.IsCan,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -462,8 +450,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug),
"is_can": row.IsCan,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -548,130 +535,6 @@ func safeRawContentType(ct string) bool {
return false
}
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
http.NotFound(w, r)
return
}
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
http.NotFound(w, r)
return
}
// #4: password-protected cans go through the same unlock flow as pastes:
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
// inherit the protection (handleCanItem checks the same cookie).
if can.PasswordHash.Valid {
h := a.webHandlers()
if r.Method == http.MethodPost {
if !rateLimitUnlock(can.ID, r) {
h.WriteRateLimited(w, 60)
return
}
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
http.SetCookie(w, &http.Cookie{
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
a.renderCan(w, can)
return
}
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": true,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
c, err := r.Cookie("pw_" + can.ID)
if err != nil || c.Value != web.UnlockToken(can.ID) {
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": false,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
}
a.renderCan(w, can)
}
// renderCan renders the can view page: title/description and items as cards.
// Text items expand inline; files link to download.
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
h := a.webHandlers()
items, err := a.store.ListCanItems(can.ID)
if err != nil {
http.Error(w, "db error", 500)
return
}
type canItem struct {
ID string
Title string
ContentType string
Size string
IsFile bool
Content string
ContentHTML template.HTML
Language string
}
cards := make([]canItem, 0, len(items))
totalSize := 0
for _, it := range items {
totalSize += len(it.Content)
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
ci := canItem{
ID: it.ID,
Title: nullStrOr(it.Title, it.ID),
ContentType: it.ContentType,
Size: web.HumanSize(len(it.Content)),
IsFile: isFile,
Language: it.Language.String,
}
if !isFile {
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
}
cards = append(cards, ci)
}
h.RenderPage(w, "can.html", map[string]any{
"Page": "can",
"ID": can.ID,
"Title": nullStrOr(can.Title, "Untitled can"),
"Description": can.Description.String,
"HasDescription": can.Description.Valid && can.Description.String != "",
"HasPassword": can.PasswordHash.Valid,
"Items": cards,
"ItemCount": len(cards),
"SizeHuman": web.HumanSize(totalSize),
"CreatedAgo": web.AgoString(can.CreatedAt),
"CreatedAtUnix": can.CreatedAt,
"ExpiresAt": can.ExpiresAt.Valid,
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
})
}
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
func expiryStringIfValid(ns sql.NullInt64) string {
if !ns.Valid {
return ""
}
remaining := ns.Int64 - time.Now().Unix()
s := remaining
switch {
case s < 3600:
return fmt.Sprintf("%dm", s/60)
case s < 86400:
return fmt.Sprintf("%dh", s/3600)
default:
return fmt.Sprintf("%dd", s/86400)
}
}
func templateEsc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
h := a.webHandlers()
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
)
// #68 input-validation helpers. Paste/can payloads are size-capped and list
// #68 input-validation helpers. Paste payloads are size-capped and list
// endpoints get a single place where limit/offset are parsed and clamped.
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
+1 -8
View File
@@ -10,7 +10,7 @@ var slugRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]{0,63}$`)
// reserved words that would collide with routes
var reservedSlugs = map[string]bool{
"api": true, "raw": true, "can": true, "cans": true, "public": true,
"api": true, "raw": true, "public": true,
"history": true, "static": true, "assets": true, "favicon.ico": true,
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
"mine": true, "saved": true, "unlock": true, "guess": true, "f": true,
@@ -36,12 +36,5 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
if err != nil {
return false, err
}
if n > 0 {
return true, nil
}
err = s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans WHERE id = ?`, slug).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil
}
+108
View File
@@ -0,0 +1,108 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
)
// Cans feature removal (2026-10-16): opening a store on a pre-removal
// database must drop the paste_cans table (only when empty) and the
// pastes.can_id column. A non-empty paste_cans table is left in place.
func TestMigrateDropsCansSchema(t *testing.T) {
dir := t.TempDir()
legacy := func(name string, canRows int) string {
db, err := sql.Open("sqlite", filepath.Join(dir, name))
if err != nil {
t.Fatalf("open raw db: %v", err)
}
defer db.Close()
stmts := []string{
`CREATE TABLE pastes (
id TEXT PRIMARY KEY, custom_slug TEXT UNIQUE, content TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'text/plain', language TEXT, title TEXT,
password_hash TEXT, expires_at INTEGER, burn_after_read INTEGER DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'public', can_id TEXT,
created_at INTEGER NOT NULL, deleted_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0, deletion_token TEXT
)`,
`CREATE TABLE paste_cans (
id TEXT PRIMARY KEY, title TEXT, description TEXT,
visibility TEXT NOT NULL DEFAULT 'public', password_hash TEXT,
created_at INTEGER NOT NULL, deleted_at INTEGER, expires_at INTEGER
)`,
`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`,
`INSERT INTO pastes (id, content, content_type, visibility, can_id, created_at)
VALUES ('p1', 'x', 'text/plain', 'public', NULL, 1)`,
}
for _, q := range stmts {
if _, err := db.Exec(q); err != nil {
t.Fatalf("seed %q: %v", q, err)
}
}
for i := 0; i < canRows; i++ {
if _, err := db.Exec(`INSERT INTO paste_cans (id, created_at) VALUES (?, 1)`, string(rune('a'+i))); err != nil {
t.Fatalf("seed can row: %v", err)
}
}
return filepath.Join(dir, name)
}
intCol := func(s *Store, table, col string) int {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, col).Scan(&n)
return n
}
tableExists := func(s *Store, table string) bool {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n)
return n > 0
}
// Empty paste_cans: table and column both dropped.
s1, err := OpenStore(legacy("empty.db", 0))
if err != nil {
t.Fatalf("open: %v", err)
}
defer s1.db.Close()
if tableExists(s1, "paste_cans") {
t.Error("paste_cans still exists after migrate (was empty; should be dropped)")
}
if intCol(s1, "pastes", "can_id") != 0 {
t.Error("pastes.can_id still exists after migrate; should be dropped")
}
// The store still works end to end.
if _, err := s1.CreatePaste(&Paste{Content: "hello", Visibility: "public"}); err != nil {
t.Fatalf("create paste on migrated db: %v", err)
}
if got := s1.QueryInt(`SELECT COUNT(*) FROM pastes`); got != 2 {
t.Errorf("pastes count = %d, want 2", got)
}
// Non-empty paste_cans: table kept, can_id still dropped.
s2, err := OpenStore(legacy("full.db", 2))
if err != nil {
t.Fatalf("open: %v", err)
}
defer s2.db.Close()
if !tableExists(s2, "paste_cans") {
t.Error("non-empty paste_cans was dropped; must be kept until cleaned")
}
if intCol(s2, "pastes", "can_id") != 0 {
t.Error("pastes.can_id still exists after migrate; should be dropped")
}
// Fresh database: neither the table nor the column is ever created.
s3, err := OpenStore(filepath.Join(dir, "fresh.db"))
if err != nil {
t.Fatalf("open fresh: %v", err)
}
defer s3.db.Close()
if tableExists(s3, "paste_cans") {
t.Error("fresh db created paste_cans")
}
if intCol(s3, "pastes", "can_id") != 0 {
t.Error("fresh db created pastes.can_id")
}
}
+38 -146
View File
@@ -32,7 +32,6 @@ type Paste struct {
Visibility string `json:"visibility"`
// #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"`
@@ -55,29 +54,15 @@ type PasteRow struct {
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
IsCan bool // set on list rows that are cans (#4)
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
}
type CanRow struct {
ID string
Title sql.NullString
Visibility string
PasswordHash sql.NullString
CreatedAt int64
DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64
Description sql.NullString
ViewerID sql.NullString
}
type Store struct {
db *sql.DB
blob BlobStore // #38: attachment byte storage (nil in some unit tests)
@@ -119,7 +104,6 @@ func (s *Store) migrate() error {
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,
@@ -128,22 +112,32 @@ deletion_token TEXT
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pastes_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(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4)
// Cans feature removal (owner decision 2026-10-16): drop the leftover
// schema. paste_cans is only dropped when empty so no data is silently
// destroyed; a non-empty table is left in place and logged. The dormant
// pastes.can_id column is dropped too (prod verified clean 2026-10-16:
// zero rows referenced a can).
var tbl int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='paste_cans'`).Scan(&tbl)
if tbl > 0 {
var rows int
s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans`).Scan(&rows)
if rows == 0 {
s.db.Exec(`DROP TABLE paste_cans`)
} else {
log.Printf("store: paste_cans still holds %d row(s); leaving the table in place (cans feature removed)", rows)
}
}
var col int
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pastes') WHERE name='can_id'`).Scan(&col)
if col > 0 {
s.db.Exec(`ALTER TABLE pastes DROP COLUMN can_id`)
}
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL,
@@ -290,10 +284,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
}
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
var r PasteRow
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -301,19 +295,15 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
}
// ListPublic backs /api/public and the public listing page. Visibility rules
// 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.
// mirror the history page: only non-deleted, non-expired 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 p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -322,34 +312,27 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
var isCan int
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &r.AttFilename); err != nil {
return nil, 0, err
}
r.CustomSlug = cs
r.Language = lang
r.Title = title
r.IsCan = isCan == 1
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?))
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
time.Now().Unix(), time.Now().Unix()).Scan(&total)
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
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 p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND p.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
UNION ALL
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, ''
FROM paste_cans
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
@@ -358,18 +341,15 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
var isCan int
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &r.AttFilename); err != nil {
return nil, 0, err
}
r.CustomSlug, r.Language, r.Title = cs, lang, title
r.IsCan = isCan == 1
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?))
+ (SELECT COUNT(*) FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
viewerID, time.Now().Unix()).Scan(&total)
return out, total, nil
}
@@ -432,8 +412,6 @@ func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool
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)
// #4: cans expire too — mirror paste behavior
s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
grace := now - SoftDeleteGraceDays*86400
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
}
@@ -490,92 +468,6 @@ func (s *Store) HardDelete(id string) {
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
}
// CreateCan inserts a paste_can row with optional custom slug (parity with
// pastes: validated by the same rules, checked against both tables).
// Returns ErrSlugTaken / ErrInvalidSlug / ErrReservedSlug on conflict.
func (s *Store) CreateCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64, customSlug *string) error {
if customSlug != nil && *customSlug != "" {
slug := *customSlug
if err := ValidateCustomSlug(slug); err != nil {
return err
}
taken, err := s.SlugTaken(slug)
if err != nil {
return err
}
if taken {
return ErrSlugTaken
}
canID = slug
}
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
return errors.New("visibility must be public or unlisted")
}
_, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
return err
}
// SoftDeleteCan marks a can deleted (its items stay; they are unlisted and
// hidden from listings by can_id and disappear with the can's page).
func (s *Store) SoftDeleteCan(canID string) (bool, error) {
res, err := s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), canID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// DeleteCan removes an (empty/aborted) can row.
func (s *Store) DeleteCan(canID string) {
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
}
// InsertCanItem adds an item paste belonging to a can.
func (s *Store) InsertCanItem(canID, title, content, contentType string, language *string, expiresAt, binary *string, now int64) error {
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
_, err := s.db.Exec(`INSERT INTO pastes
(id, content, content_type, language, title, visibility, can_id, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
_ = expiresAt
_ = binary
return err
}
func (s *Store) GetCan(id string) (*CanRow, error) {
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at, description, viewer_id
FROM paste_cans WHERE (id = ?) AND deleted_at IS NULL`, id)
var c CanRow
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt, &c.Description, &c.ViewerID)
if err == sql.ErrNoRows {
return nil, nil
}
return &c, err
}
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PasteRow
for rows.Next() {
var r PasteRow
if err := rows.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// GenSlug is the exported slug generator.
func GenSlug(n int) string { return genSlug(n) }
-21
View File
@@ -694,26 +694,6 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
.center .foot { font-size: 13px; }
}
/* #4: can view page + can builder */
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.can-item { padding: 12px 14px; }
.can-item-head { display: flex; align-items: center; gap: 10px; }
.can-item-head strong { flex: 1; word-break: break-all; }
.can-item-body { margin-top: 8px; }
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
/* #169: divider between the dropdown title and body must be persistent
(not hover dependent) and extend the full width of the card left and right */
.can-item-body[open] summary {
border-bottom: 1px solid var(--border);
margin: 0 -14px 8px;
padding: 0 14px 8px;
}
.can-item-body pre.code { margin: 8px 0 0; overflow-x: auto; }
.can-item-row { margin-top: 8px; }
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
/* ============================================================
Styled controls (#123) — buttons, selects, checkboxes, radios
match the custom URL box house pattern: 1px var(--border) on
@@ -892,7 +872,6 @@ button[type="submit"]:focus-visible,
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
.hidden { display: none; }
.can-page { max-width: 900px; width: 100%; }
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
.col-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
.col-f { width: 100px; } .col-g { width: 120px; }
+2 -2
View File
@@ -6,8 +6,8 @@ const t = PaletteTable.init({
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
+2 -2
View File
@@ -18,8 +18,8 @@ const t = PaletteTable.init({
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
-36
View File
@@ -1,36 +0,0 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float can-page">
<div class="inner">
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
<p class="hint">{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}</p>
<div class="can-items">
{{range .Items}}
<div class="can-item float">
<div class="can-item-head">
<strong>{{.Title}}</strong>
<span class="dim">{{.Size}}</span>
{{if .IsFile}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" download="{{.Title}}" title="Download file" aria-label="Download {{.Title}}">↓</a>
{{else}}
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" title="Raw item" aria-label="Raw {{.Title}}">⧉</a>
{{end}}
</div>
{{if not .IsFile}}
<details class="can-item-body">
<summary>Show content</summary>
<pre class="code"><code>{{.ContentHTML}}</code></pre>
</details>
{{else}}
<div class="hint">Attached file — use the download button above.</div>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
{{template "foot" .}}
+5 -28
View File
@@ -1,5 +1,5 @@
// 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 serves palette's HTML routes: paste pages, unlock, and the
// admin page. Templates and static assets are embedded in this package.
package web
import (
@@ -111,29 +111,6 @@ func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
h.renderNotFound(w, r, r.URL.Path)
}
// RenderPage is the exported wrapper used by the api package (#4 can pages).
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
h.renderPage(w, name, data)
}
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
h.writeRateLimited(w, retryAfterSecs)
}
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
// Exported for the api package so can pages share paste cookie semantics (#4).
func UnlockToken(id string) string { return unlockToken(id) }
// AgoString formats a relative "N ago" string (exported for the api package, #4).
func AgoString(ts int64) string { return agoString(ts) }
// HumanSize formats a byte count as a human string (exported for the api package, #4).
func HumanSize(n int) string { return humanSize(n) }
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
// (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
@@ -369,7 +346,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
// 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,
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
})
// re-render without lock, or redirect if ?next= was given (#26)
if next := r.FormValue("next"); next != "" {
@@ -403,7 +380,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
}
if justCreated && token != "" {
// one-time display of the deletion token via sessionStorage (#143)
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true})
}
// Count the view for real page renders, deduped per-viewer within the
// burn window (#95): a reload by the same vwr cookie doesn't inflate
@@ -466,7 +443,7 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
// #59: security headers for rendered HTML pages. Applied wherever the
// response is text/html (page templates and the inline can page); JSON API
// response is text/html (page templates); JSON API
// responses and /raw content pass through untouched.
// #139: script-src and style-src no longer allow 'unsafe-inline'. All
// previously-inline scripts moved to external files under static/ (page data