3 Commits
13 changed files with 946 additions and 44 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ a web UI for sharing code and text with links that expire on your terms.
## Features ## Features
- Paste cans — bundle notes, text, and files into one shareable page - Paste cans — bundle notes, text, and files into one shareable page, with password/expiry/custom-slug parity and a `can` badge in listings
- Password lock — protect individual pastes with a password - Password lock — protect individual pastes with a password
- Custom expiry — from 1 minute up to 1 year, or never - 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 - Burn after N reads — a paste that vanishes after a chosen number of reads
+11
View File
@@ -84,18 +84,29 @@ curl -X POST http://localhost:8080/api/guess-language \
## Cans (bundles of items) ## Cans (bundles of items)
A can bundles multiple text items (and files) into one shareable page at
`/can/{id}`. Password, expiry, visibility, custom slug, and viewer-scoped
deletion work exactly like pastes; cans appear as normal rows (with a `can`
badge) in `/api/public` and `/api/mine`.
```bash ```bash
curl -X POST http://localhost:8080/api/pastes/can \ curl -X POST http://localhost:8080/api/pastes/can \
-F "title=My bundle" \ -F "title=My bundle" \
-F "expires_in=48h" \ -F "expires_in=48h" \
-F "custom_slug=my-bundle" \
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \ -F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
-F "files=@screenshot.png" \ -F "files=@screenshot.png" \
-F "files=@log.txt" -F "files=@log.txt"
curl http://localhost:8080/api/cans/{id} curl http://localhost:8080/api/cans/{id}
curl http://localhost:8080/api/cans/{id}/items/{item_id} curl http://localhost:8080/api/cans/{id}/items/{item_id}
curl -X DELETE http://localhost:8080/api/cans/{id} # creator browser only (vwr cookie)
``` ```
Password-protected cans use the same unlock flow as pastes: `POST /can/{id}`
with the password sets an HMAC-bound cookie; item fetches accept the cookie as
well as the `X-Paste-Password` header / `?password=` query param.
## Web pages ## Web pages
- `/new` — create a paste - `/new` — create a paste
+60
View File
@@ -0,0 +1,60 @@
# Performance notes (#32)
Background: history and saved pages filter client-side. Each load fetches the
most recent rows from the list endpoint (`limit=500` per query is the current
client cap in `static/table.js`) and filters/sorts in the browser. This note
records current behavior, measured latency, and the design for a future
server-side search endpoint. Measurements only — no implementation in #4/#32.
## Current behavior
- `/api/public?limit=500&offset=0` and `/api/mine?limit=500&offset=0` return up
to 500 rows (id, title, language, created_at, view_count, size,
custom_slug, is_can). Content is NOT included — only `LENGTH(content)`.
- The browser applies the search-box filter (title/language/id substring) and
column sorting locally over the fetched window.
- Consequence: search only covers the fetched window (500 most recent rows).
Older rows are invisible to search until paginated through, and each query
ships ~4 KB of row metadata regardless of how few rows the user will look at.
## Measured latency (synthetic rows, scratch SQLite DB)
Rows are synthetic pastes (~200 B content each, indexed like production:
`idx_pastes_visibility_created`). Queried `GET /api/public?limit=500`
(modernc.org/sqlite, WAL, single connection — same as production).
| Rows in table | Bulk insert | First query | Avg query (10 runs) | Payload |
|---|---|---|---|---|
| 1,000 | 19 ms | 1.0 ms | 0.44 ms | ~4.1 KB |
| 5,000 | 95 ms | 1.0 ms | 0.98 ms | ~4.1 KB |
| 10,000 | 189 ms | 2.0 ms | 1.78 ms | ~4.1 KB |
Interpretation:
- The list query itself is cheap (< 2 ms at 10k rows); latency users perceive
comes from network + browser rendering of 500 rows, not SQL.
- The current design scales fine to ~10k pastes. Beyond that, shipping 500
rows per keystroke-refresh cycle is wasteful and search coverage stays
capped at the window.
## Future design: server-side `/api/search?q=` (#32 remainder)
- Endpoint: `GET /api/search?q=<term>&limit=25&offset=0`.
- SQL: `SELECT ... FROM pastes WHERE deleted_at IS NULL AND (expires_at IS NULL
OR expires_at > ?) AND (title LIKE ? OR content LIKE ?) ORDER BY created_at
DESC LIMIT ? OFFSET ?` — term wrapped as `%term%`, escaped (`%`, `_`).
Visibility scoping mirrors ListPublic/ListMine (`public` + `viewer_id` for
the saved-page variant).
- Indexing: LIKE with a leading wildcard cannot use a B-tree index. Options,
in order of effort:
1. Accept a table scan — fine at ≤ ~50k rows (10k rows scanned in ~2 ms).
2. Add an index on `title` for prefix search (`q*`) and keep `%q%` scan only
as a fallback.
3. SQLite FTS5 virtual table (`CREATE VIRTUAL TABLE pastes_fts USING
fts5(title, content)`) for token search — best relevance, needs sync on
insert/delete and a migration.
- Cans: search should cover can titles/descriptions too (UNION ALL with
`paste_cans`, `is_can=1`), matching the #4 listing integration.
- Response shape: same row objects as `/api/public` (plus `is_can`) so
`table.js` can render results without a second code path; the client filter
becomes a server query when `q` is non-empty.
+48 -3
View File
@@ -10,6 +10,8 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"palette/internal/web"
) )
// CreateCan makes a can with N items (multipart form). // CreateCan makes a can with N items (multipart form).
@@ -31,6 +33,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
} }
expiresIn := r.FormValue("expires_in") expiresIn := r.FormValue("expires_in")
password := r.FormValue("password") password := r.FormValue("password")
customSlug := r.FormValue("custom_slug")
var expiresAt *int64 var expiresAt *int64
now := time.Now().Unix() now := time.Now().Unix()
@@ -40,7 +43,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "invalid expires_in") writeErr(w, 400, "invalid expires_in")
return return
} }
// #60: clamp at the API boundary like the pastes API does - // #60/#48: clamp at the API boundary like the pastes API does -
// reject zero/negative and durations past the 1-year UI cap. // reject zero/negative and durations past the 1-year UI cap.
if !store.ValidExpiry(d) { if !store.ValidExpiry(d) {
writeErr(w, 400, "expires_in must be between 1 minute and 1 year") writeErr(w, 400, "expires_in must be between 1 minute and 1 year")
@@ -60,11 +63,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
} }
canID := store.GenSlug(8) canID := store.GenSlug(8)
err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt) var slugPtr *string
if customSlug != "" {
slugPtr = &customSlug
}
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
if err != nil { if err != nil {
switch err {
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
writeErr(w, 409, err.Error())
default:
writeErr(w, 500, "db error") writeErr(w, 500, "db error")
}
return 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":"..."}] // text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
itemCount := 0 itemCount := 0
@@ -129,6 +146,29 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
}) })
} }
// 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 { func detectContentType(name string, content []byte) string {
lower := strings.ToLower(name) lower := strings.ToLower(name)
switch { switch {
@@ -211,7 +251,8 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not a can item") writeErr(w, 404, "not a can item")
return return
} }
// inherit can password protection // 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) can, _ := a.store.GetCan(row.CanID.String)
if can != nil && can.PasswordHash.Valid { if can != nil && can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password") pw := r.Header.Get("X-Paste-Password")
@@ -219,10 +260,14 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
pw = r.URL.Query().Get("password") pw = r.URL.Query().Get("password")
} }
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) { 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") writeErr(w, 401, "password required")
return return
} }
} }
}
// #34: same content-type guard as /raw — never serve active content types. // #34: same content-type guard as /raw — never serve active content types.
ct := row.ContentType ct := row.ContentType
if !safeRawContentType(ct) { if !safeRawContentType(ct) {
+413
View File
@@ -0,0 +1,413 @@
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+"?password=pw123", nil)
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
}
+118 -9
View File
@@ -4,8 +4,10 @@ package api
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
@@ -15,8 +17,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"database/sql" langpkg "palette/internal/lang"
"palette/internal/store" "palette/internal/store"
"palette/internal/web" "palette/internal/web"
) )
@@ -79,11 +80,13 @@ func (a *apiServer) routes() http.Handler {
r.Post("/guess-language", a.handleGuessLang) r.Post("/guess-language", a.handleGuessLang)
r.Post("/pastes/can", a.handleCreateCan) r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan) r.Get("/cans/{id}", a.handleGetCan)
r.Delete("/cans/{id}", a.handleDeleteCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem) r.Get("/cans/{id}/items/{item}", a.handleCanItem)
}) })
// can page // can page (#4): GET renders, POST unlocks (same flow as pastes)
r.Get("/can/{id}", a.handleCanPage) r.Get("/can/{id}", a.handleCanPage)
r.Post("/can/{id}", a.handleCanPage)
// raw // raw
r.Get("/raw/{id}", a.handleRaw) r.Get("/raw/{id}", a.handleRaw)
@@ -340,6 +343,7 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
"id": row.ID, "title": title, "language": lang, "id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility, "custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
"is_can": row.IsCan,
}) })
} }
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -360,6 +364,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
"id": row.ID, "title": title, "language": lang, "id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "custom_slug": store.NullStrPtr(row.CustomSlug),
"is_can": row.IsCan,
}) })
} }
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -403,7 +408,7 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", ct) w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
a.store.IncrementViews(row.ID) a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
w.Write([]byte(row.Content)) w.Write([]byte(row.Content))
} }
@@ -432,13 +437,117 @@ func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
items, _ := a.store.ListCanItems(can.ID) if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
w.Header().Set("Content-Type", "text/html; charset=utf-8") http.NotFound(w, r)
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID) 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 { 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) 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)
} }
fmt.Fprintf(w, "</ul></body></html>")
} }
func templateEsc(s string) string { func templateEsc(s string) string {
+92 -16
View File
@@ -62,6 +62,7 @@ type PasteRow struct {
Size int Size int
DeletionToken sql.NullString DeletionToken sql.NullString
ViewerID sql.NullString ViewerID sql.NullString
IsCan bool // set on list rows that are cans (#4)
} }
type CanRow struct { type CanRow struct {
@@ -72,6 +73,8 @@ type CanRow struct {
CreatedAt int64 CreatedAt int64
DeletedAt sql.NullInt64 DeletedAt sql.NullInt64
ExpiresAt sql.NullInt64 ExpiresAt sql.NullInt64
Description sql.NullString
ViewerID sql.NullString
} }
type Store struct { type Store struct {
@@ -132,6 +135,7 @@ deletion_token TEXT
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37) s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49) s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49) s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4)
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views ( s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL, paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL, viewer_id TEXT NOT NULL,
@@ -272,9 +276,14 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
// listed, and password-protected pastes are excluded at the query level // listed, and password-protected pastes are excluded at the query level
// (#65) so their metadata (title, slug, existence) never leaks. // (#65) so their metadata (title, slug, existence) never leaks.
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content), 0
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 > ?) 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) 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)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -283,25 +292,33 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
for rows.Next() { for rows.Next() {
var r PasteRow var r PasteRow
var cs, lang, title sql.NullString var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil { var isCan int
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil {
return nil, 0, err return nil, 0, err
} }
r.CustomSlug = cs r.CustomSlug = cs
r.Language = lang r.Language = lang
r.Title = title r.Title = title
r.IsCan = isCan == 1
out = append(out, r) out = append(out, r)
} }
var total int var total int
s.db.QueryRow(`SELECT 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) 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)
return out, total, nil return out, total, nil
} }
// ListMine lists pastes created from the given viewer id (browser cookie), newest first. // ListMine lists pastes created from the given viewer id (browser cookie), newest first.
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) { func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content) rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content), 0
FROM pastes FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) 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) 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)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -310,16 +327,18 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
for rows.Next() { for rows.Next() {
var r PasteRow var r PasteRow
var cs, lang, title sql.NullString var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil { var isCan int
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil {
return nil, 0, err return nil, 0, err
} }
r.CustomSlug, r.Language, r.Title = cs, lang, title r.CustomSlug, r.Language, r.Title = cs, lang, title
r.IsCan = isCan == 1
out = append(out, r) out = append(out, r)
} }
var total int var total int
s.db.QueryRow(`SELECT COUNT(*) FROM pastes 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 > ?))
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()).Scan(&total) viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
return out, total, nil return out, total, nil
} }
@@ -352,14 +371,38 @@ func (s *Store) SoftDelete(id string) (bool, error) {
return n > 0, err return n > 0, err
} }
func (s *Store) IncrementViews(id string) { // IncrementViews counts one view. With a viewerID (#95): views are deduped
// per-viewer within burnViewerWindow minutes using the paste_views table,
// namespaced with a "views/" viewer prefix so these rows never collide with
// RegisterRead's burn-after-read dedupe rows (which key on the raw viewer id).
// Raw views always count (#49 decision) — call with viewerID="" for those.
// Returns true when the view was counted.
func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool {
if viewerID == "" {
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id) s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
}
now := TimeNow().Unix()
vkey := "views/" + viewerID
var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
id, vkey).Scan(&last)
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
return false
}
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
id, vkey, now)
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
} }
// SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace. // SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace.
func (s *Store) SweepExpired() { func (s *Store) SweepExpired() {
now := time.Now().Unix() now := time.Now().Unix()
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now) s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
// #4: cans expire too — mirror paste behavior
s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
grace := now - SoftDeleteGraceDays*86400 grace := now - SoftDeleteGraceDays*86400
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace) s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
} }
@@ -416,13 +459,46 @@ func (s *Store) HardDelete(id string) {
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id) s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
} }
// InsertCan creates a paste_can row. // CreateCan inserts a paste_can row with optional custom slug (parity with
func (s *Store) InsertCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64) error { // 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) _, 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) VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
return err 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. // DeleteCan removes an (empty/aborted) can row.
func (s *Store) DeleteCan(canID string) { func (s *Store) DeleteCan(canID string) {
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID) s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
@@ -441,10 +517,10 @@ func (s *Store) InsertCanItem(canID, title, content, contentType string, languag
} }
func (s *Store) GetCan(id string) (*CanRow, error) { func (s *Store) GetCan(id string) (*CanRow, error) {
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at 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) FROM paste_cans WHERE (id = ?) AND deleted_at IS NULL`, id)
var c CanRow var c CanRow
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt) 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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
+31
View File
@@ -165,6 +165,20 @@ body {
.settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); } .settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); }
.settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; } .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; } .settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; }
.settings-section-title { color: var(--fg); font-size: 24px; margin-bottom: 6px; }
/* theme switcher cards (#100) */
.theme-grid { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; }
.theme-card {
display: flex; flex-direction: column; gap: 10px; align-items: flex-start;
padding: 14px 16px; min-width: 180px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--fg); font: inherit; font-size: 20px; cursor: pointer;
}
.theme-card:hover { border-color: var(--muted); }
.theme-card[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-card .swatches { display: flex; gap: 4px; }
.theme-card .swatch { width: 26px; height: 26px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); }
.theme-name { font-weight: 600; }
.iconbtn:hover { color: var(--fg); border-color: var(--muted); } .iconbtn:hover { color: var(--fg); border-color: var(--muted); }
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; } .iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
.code-head { .code-head {
@@ -201,6 +215,10 @@ th {
text-align: left; font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em; 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; color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600;
} }
/* #92: the sort-ind span is absolutely positioned in the cell's right gutter,
so it can't shift the header label right of the data cells. */
th.sortable { cursor: pointer; user-select: none; position: relative; padding-right: 34px; }
th.sortable .sort-ind { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); margin-left: 0; }
td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 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:last-child td { border-bottom: none; }
tr.row { cursor: pointer; } tr.row { cursor: pointer; }
@@ -456,3 +474,16 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.unlock-err { font-size: 13px; } .unlock-err { font-size: 13px; }
.center .foot { font-size: 13px; } .center .foot { font-size: 13px; }
} }
/* #4: can view page + can builder */
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.can-item { padding: 12px 14px; }
.can-item-head { display: flex; align-items: center; gap: 10px; }
.can-item-head strong { flex: 1; word-break: break-all; }
.can-item-body { margin-top: 8px; }
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
.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; }
+36
View File
@@ -0,0 +1,36 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float can-page" style="max-width:900px; width:100%;">
<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" .}}
+2 -2
View File
@@ -36,8 +36,8 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}` ? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) + : `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></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>` + `<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>` +
+8 -4
View File
@@ -21,6 +21,10 @@
</table> </table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div> <div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
</div> </div>
<div class="pager float">
<span id="showing"></span>
<div class="pg" id="pg"></div>
</div>
</div> </div>
<script src="/static/table.js"></script> <script src="/static/table.js"></script>
<script> <script>
@@ -38,13 +42,13 @@ function toast(msg, kind) {
const t = PaletteTable.init({ const t = PaletteTable.init({
endpoint: '/api/mine', endpoint: '/api/mine',
perPage: 50, perPage: 25,
hasPager: false, hasPager: true,
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}` ? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) + : `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></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>` + `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
+83
View File
@@ -68,6 +68,15 @@
<input type="text" id="custom" class="custom-input" placeholder="/my-snippet"> <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 class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
</div> </div>
<div class="float side-section">
<h3>Can contents</h3>
<label class="toggle"><input type="checkbox" id="iscan"> Bundle as a can (multiple text items)</label>
<div class="pw-row" id="canrow" style="display:none">
<div id="can-items"></div>
<button class="btn btn-icon" id="can-add" type="button" title="Add item" style="margin-top:6px">+ Add item</button>
<div class="hint" style="margin-top:6px; font-size:19px;">Each item gets its own card on the can page. File uploads in cans come later.</div>
</div>
</div>
<div class="float side-section" id="result-card" style="display:none"> <div class="float side-section" id="result-card" style="display:none">
<h3>Result</h3> <h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all">empty</div> <div class="hint" id="result" style="word-break:break-all">empty</div>
@@ -145,6 +154,21 @@ $('pwreveal').addEventListener('click', () => {
$('pwreveal').title = show ? 'Hide password' : 'Show password'; $('pwreveal').title = show ? 'Hide password' : 'Show password';
}); });
// #4: can builder — multiple text items bundled into one shareable page.
// The main editor becomes the first item; extra items are added below.
$('iscan').addEventListener('change', e => {
$('canrow').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked && !$('can-items').children.length) addCanItem();
});
function addCanItem() {
const row = document.createElement('div');
row.className = 'can-item-row';
row.innerHTML = '<input class="can-item-title" placeholder="Item title">' +
'<textarea class="can-item-content" placeholder="Item content" rows="3" spellcheck="false"></textarea>';
$('can-items').appendChild(row);
}
$('can-add').addEventListener('click', addCanItem);
let guessed = ''; // last auto-detected language, '' = user override let guessed = ''; // last auto-detected language, '' = user override
function showResult(html, isError) { function showResult(html, isError) {
@@ -197,6 +221,9 @@ $('reguess').addEventListener('click', guessLang);
content.addEventListener('paste', () => setTimeout(guessLang, 0)); content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() { async function create() {
// #4: can mode — bundle the editor + extra items into a can via multipart
if ($('iscan').checked) return createCan();
const body = { const body = {
content: content.value, content: content.value,
title: $('title').value || null, title: $('title').value || null,
@@ -252,6 +279,62 @@ async function create() {
location.href = dest; location.href = dest;
} }
$('create').addEventListener('click', create); $('create').addEventListener('click', create);
// #4: can creation — POST multipart to /api/pastes/can. The main editor is
// the first item; each extra can-item row is another text item.
async function createCan() {
const items = [];
if (content.value.trim()) {
items.push({title: $('title').value || 'main', content: content.value, language: $('language').value || ''});
}
document.querySelectorAll('#can-items .can-item-row').forEach(row => {
const t = row.querySelector('.can-item-title').value.trim();
const c = row.querySelector('.can-item-content').value;
if (c.trim()) items.push({title: t || ('item-' + (items.length + 1)), content: c});
});
if (!items.length) { toast('Nothing to put in the can', 'error'); return; }
const fd = new FormData();
fd.append('title', $('title').value || 'Untitled can');
fd.append('json_items', JSON.stringify(items));
if ($('haspw').checked) fd.append('password', $('password').value);
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
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; }
if (dur) fd.append('expires_in', dur);
} else if (exp) {
fd.append('expires_in', exp);
}
if ($('custom').value.trim()) fd.append('custom_slug', $('custom').value.trim());
const res = await fetch('/api/pastes/can', {method: 'POST', body: fd});
const data = await res.json();
if (!res.ok) {
showResult('Error: ' + (data.error || res.status), true);
toast('Can create failed', 'error');
return;
}
const url = location.origin + data.url;
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok');
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// password-protected can: unlock now with the password we already have (#26 parity)
if ($('haspw').checked && data.id) {
const pd = new FormData();
pd.append('password', $('password').value);
try { await fetch('/can/' + data.id, {method: 'POST', body: pd}); } catch(e) {}
}
location.href = data.url;
}
// reset stale result state when returning via Back (bfcache) (#28) // reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => { window.addEventListener('pageshow', e => {
if (!e.persisted) return; if (!e.persisted) return;
+40 -6
View File
@@ -34,6 +34,7 @@ type UI struct {
func New() (*UI, error) { func New() (*UI, error) {
funcs := template.FuncMap{ funcs := template.FuncMap{
"humanSize": humanSize, "humanSize": humanSize,
"version": func() string { return Version }, // #93: topbar version label
} }
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html") t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
if err != nil { if err != nil {
@@ -64,6 +65,29 @@ func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
} }
} }
// RenderPage is the exported wrapper used by the api package (#4 can pages).
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
h.renderPage(w, name, data)
}
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
h.writeRateLimited(w, retryAfterSecs)
}
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
// Exported for the api package so can pages share paste cookie semantics (#4).
func UnlockToken(id string) string { return unlockToken(id) }
// AgoString formats a relative "N ago" string (exported for the api package, #4).
func AgoString(ts int64) string { return agoString(ts) }
// HumanSize formats a byte count as a human string (exported for the api package, #4).
func HumanSize(n int) string { return humanSize(n) }
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
// #34: per-paste unlock tokens. unlockSecret is generated once at startup // #34: per-paste unlock tokens. unlockSecret is generated once at startup
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and // (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by // used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
@@ -245,12 +269,13 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
// one-time display of the deletion token via the created banner // 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}) 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 // Count the view for real page renders, deduped per-viewer within the
// handleRaw; the HTML path was missing its increment, so view_count only // burn window (#95): a reload by the same vwr cookie doesn't inflate
// ever moved via /raw and the API-stored count stayed at 0 (#33). // view_count. Raw views increment unconditionally in handleRaw (#49); the
// The just-created banner render does not count as a view. // HTML path was missing its increment originally (#33). The just-created
// banner render does not count as a view.
if !justCreated { if !justCreated {
h.Store.IncrementViews(row.ID) h.Store.IncrementViews(row.ID, h.ViewerID(r), h.BurnWindowMin())
} }
// #49: burn-after-N-reads budget (per-viewer dedupe window). // #49: burn-after-N-reads budget (per-viewer dedupe window).
// Just-created first render does not count as a read for the creator. // Just-created first render does not count as a read for the creator.
@@ -279,7 +304,16 @@ func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
// HandleSettingsPage serves /settings. // HandleSettingsPage serves /settings.
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "settings.html", map[string]any{"Page": "settings"}) // #100: theme presets. Midnight is the default (no data-preset attribute),
// so its swatches are hardcoded here; the CSS defines the token values.
themes := []map[string]any{
{"Name": "midnight", "Label": "Midnight", "Swatches": []string{"#241B30", "#2D2340", "#3A2D52", "#C4A8F0", "#F2EDF8"}},
{"Name": "smooth", "Label": "Smooth", "Swatches": []string{"#F6F5FA", "#FFFFFF", "#DAD7E6", "#7A7796", "#2A2A36"}},
{"Name": "pastel-lavender", "Label": "Pastel Lavender", "Swatches": []string{"#e6e0f5", "#f1edfa", "#cbb8e7", "#806bb8", "#3E3059"}},
{"Name": "pastel-peach", "Label": "Pastel Peach", "Swatches": []string{"#ffe0d6", "#fff0ea", "#ffc4a8", "#f9826c", "#4F2318"}},
{"Name": "pastel-cloud", "Label": "Pastel Cloud", "Swatches": []string{"#fff0f6", "#fff7fb", "#ffc8dd", "#a2d2ff", "#4A3355"}},
}
h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes})
} }
// HandleMinePage serves /mine. // HandleMinePage serves /mine.