diff --git a/README.md b/README.md index a8d9126..1a17b0c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ a web UI for sharing code and text with links that expire on your terms. ## 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 - 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 diff --git a/docs/API.md b/docs/API.md index 5dfb1c4..f6ada59 100644 --- a/docs/API.md +++ b/docs/API.md @@ -80,18 +80,29 @@ curl -X POST http://localhost:8080/api/guess-language \ ## 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 curl -X POST http://localhost:8080/api/pastes/can \ -F "title=My bundle" \ -F "expires_in=48h" \ + -F "custom_slug=my-bundle" \ -F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \ -F "files=@screenshot.png" \ -F "files=@log.txt" curl http://localhost:8080/api/cans/{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 - `/new` — create a paste diff --git a/docs/performance-notes.md b/docs/performance-notes.md new file mode 100644 index 0000000..cee739b --- /dev/null +++ b/docs/performance-notes.md @@ -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=&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. diff --git a/internal/api/cans.go b/internal/api/cans.go index c014e44..1f330ab 100644 --- a/internal/api/cans.go +++ b/internal/api/cans.go @@ -10,6 +10,8 @@ import ( "time" "github.com/go-chi/chi/v5" + + "palette/internal/web" ) // 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") password := r.FormValue("password") + customSlug := r.FormValue("custom_slug") var expiresAt *int64 now := time.Now().Unix() @@ -40,6 +43,11 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "invalid expires_in") return } + // parity with pastes (#48): same expiry window is enforced + if !store.ValidExpiry(d) { + writeErr(w, 400, "expires_in must be between 1 minute and 1 year") + return + } t := now + int64(d.Seconds()) expiresAt = &t } @@ -54,11 +62,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) { } 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 { - writeErr(w, 500, "db error") + switch err { + case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug: + writeErr(w, 409, 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 @@ -123,6 +145,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 { lower := strings.ToLower(name) switch { @@ -207,7 +252,8 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "not a can item") return } - // inherit can password protection + // inherit can password protection: password via header/query, or the + // same pw_ 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") @@ -215,8 +261,12 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) { pw = r.URL.Query().Get("password") } if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) { - writeErr(w, 401, "password required") - return + // 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. diff --git a/internal/api/cans_flow_test.go b/internal/api/cans_flow_test.go new file mode 100644 index 0000000..308587d --- /dev/null +++ b/internal/api/cans_flow_test.go @@ -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 +} diff --git a/internal/api/server.go b/internal/api/server.go index 3556706..9e3de65 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -4,8 +4,10 @@ package api import ( "context" + "database/sql" "encoding/json" "fmt" + "html/template" "net/http" "os" "strconv" @@ -15,8 +17,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "database/sql" - + langpkg "palette/internal/lang" "palette/internal/store" "palette/internal/web" ) @@ -77,11 +78,13 @@ func (a *apiServer) routes() http.Handler { 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 + // can page (#4): GET renders, POST unlocks (same flow as pastes) r.Get("/can/{id}", a.handleCanPage) + r.Post("/can/{id}", a.handleCanPage) // raw r.Get("/raw/{id}", a.handleRaw) @@ -307,6 +310,7 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) { "id": row.ID, "title": title, "language": lang, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility, + "is_can": row.IsCan, }) } writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) @@ -330,6 +334,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) { "id": row.ID, "title": title, "language": lang, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "custom_slug": store.NullStrPtr(row.CustomSlug), + "is_can": row.IsCan, }) } writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) @@ -373,7 +378,7 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", ct) 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)) } @@ -402,13 +407,117 @@ func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - items, _ := a.store.ListCanItems(can.ID) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, "can/%s — palette

can/%s

") } func templateEsc(s string) string { diff --git a/internal/store/store.go b/internal/store/store.go index 0cc1512..2cedf48 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -60,6 +60,7 @@ type PasteRow struct { Size int DeletionToken sql.NullString ViewerID sql.NullString + IsCan bool // set on list rows that are cans (#4) } type CanRow struct { @@ -70,6 +71,8 @@ type CanRow struct { CreatedAt int64 DeletedAt sql.NullInt64 ExpiresAt sql.NullInt64 + Description sql.NullString + ViewerID sql.NullString } type Store struct { @@ -130,6 +133,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 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) s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views ( paste_id TEXT NOT NULL, viewer_id TEXT NOT NULL, @@ -258,9 +262,14 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, 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 (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 { return nil, 0, err } @@ -269,25 +278,33 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { for rows.Next() { var r PasteRow var cs, lang, title sql.NullString - if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil { + 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 } r.CustomSlug = cs r.Language = lang r.Title = title + r.IsCan = isCan == 1 out = append(out, r) } var total int - s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total) + s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)) + + (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 } // ListMine lists pastes created from the given viewer id (browser cookie), newest first. func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) { - rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content) + rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content), 0 FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) - ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset) + 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 { return nil, 0, err } @@ -296,16 +313,18 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e for rows.Next() { var r PasteRow var cs, lang, title sql.NullString - if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil { + 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 } r.CustomSlug, r.Language, r.Title = cs, lang, title + r.IsCan = isCan == 1 out = append(out, r) } var total int - s.db.QueryRow(`SELECT COUNT(*) FROM pastes - WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, - viewerID, time.Now().Unix()).Scan(&total) + 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) return out, total, nil } @@ -338,14 +357,38 @@ func (s *Store) SoftDelete(id string) (bool, error) { 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) + 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. 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) } @@ -402,13 +445,46 @@ func (s *Store) HardDelete(id string) { s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id) } -// InsertCan creates a paste_can row. -func (s *Store) InsertCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64) error { +// 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) @@ -427,10 +503,10 @@ func (s *Store) InsertCanItem(canID, title, content, contentType string, languag } func (s *Store) GetCan(id string) (*CanRow, error) { - row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at - FROM paste_cans WHERE id = ? AND deleted_at IS NULL`, id) + 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) + 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 } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d6f433c..f2bf9ac 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -165,6 +165,20 @@ body { .settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); } .settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; } .settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; } +.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.danger:hover { color: #ff8fa3; border-color: #ff8fa3; } .code-head { @@ -201,6 +215,10 @@ th { text-align: left; font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600; } +/* #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; } tr:last-child td { border-bottom: none; } tr.row { cursor: pointer; } @@ -456,3 +474,16 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); } .unlock-err { 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; } diff --git a/internal/web/templates/can.html b/internal/web/templates/can.html new file mode 100644 index 0000000..2c21ae4 --- /dev/null +++ b/internal/web/templates/can.html @@ -0,0 +1,36 @@ +{{template "head" .}} +{{template "topbar" .}} +
+
+
+

{{.Title}} can

+ {{if .HasDescription}}

{{.Description}}

{{end}} +

{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created {{.CreatedAgo}}{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}

+ +
+ {{range .Items}} +
+
+ {{.Title}} + {{.Size}} + {{if .IsFile}} + + {{else}} + + {{end}} +
+ {{if not .IsFile}} +
+ Show content +
{{.ContentHTML}}
+
+ {{else}} +
Attached file — use the download button above.
+ {{end}} +
+ {{end}} +
+
+
+
+{{template "foot" .}} diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index 5d1e71c..b6018d0 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -36,8 +36,8 @@ const t = PaletteTable.init({ rowHtml: it => `` + (it.title - ? `${t.esc(it.title)}` - : `${t.esc(it.id)}`) + + ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` + : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + `${t.esc(it.language || 'text')}` + `${t.fmtSize(it.size)}${it.view_count}${t.ago(it.created_at)}` + diff --git a/internal/web/templates/mine.html b/internal/web/templates/mine.html index e9a6389..f5def5b 100644 --- a/internal/web/templates/mine.html +++ b/internal/web/templates/mine.html @@ -21,6 +21,10 @@ +
+ +
+