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