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