My pastes page /mine with anonymous viewer cookie (#37)
CI / test (push) Successful in 21s
CI / docker (push) Skipped

- vwr cookie middleware: random browser id set on first visit (reused by #49)
- pastes table gains viewer_id column, set server-side at creation from the cookie
- GET /api/mine lists pastes for the requesting browser (title/lang/size/created)
- DELETE enforcement: 403 when client-sent vwr doesn't match the paste's viewer_id
- /mine page reuses history table styling, delete buttons, empty state
- nav: 'Saved' item between Public and Git; Git gets external-link arrow (#56)
- tests: create-with-cookie appears in /mine, other cookie doesn't, delete enforcement

Closes #37
This commit is contained in:
2026-09-08 22:10:05 -05:00
parent e83303a428
commit 129934b645
6 changed files with 339 additions and 6 deletions
+132 -5
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"database/sql"
"embed"
"encoding/json"
@@ -48,6 +49,7 @@ type Paste struct {
CreatedAt int64 `json:"created_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
ViewerID string `json:"-"` // set from vwr cookie server-side (#37)
ViewCount int `json:"view_count"`
DeletionToken string `json:"-"`
}
@@ -69,6 +71,7 @@ type PasteRow struct {
ViewCount int
Size int
DeletionToken sql.NullString
ViewerID sql.NullString
}
type CanRow struct {
@@ -131,6 +134,7 @@ func (s *Store) migrate() error {
);
`)
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)
return err
}
@@ -207,9 +211,9 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
}
p.DeletionToken = genDeletionToken()
_, err := s.db.Exec(`INSERT INTO pastes
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken)
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID)
if err != nil {
return nil, err
}
@@ -221,10 +225,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
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
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)
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)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -256,6 +260,49 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
return out, total, nil
}
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content)
FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []PasteRow
for rows.Next() {
var r PasteRow
var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
return nil, 0, err
}
r.CustomSlug, r.Language, r.Title = cs, lang, title
out = append(out, r)
}
var total int
s.db.QueryRow(`SELECT COUNT(*) FROM pastes
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
viewerID, time.Now().Unix()).Scan(&total)
return out, total, nil
}
// MineOwner returns the stored viewer_id for a paste, or "" if none.
func (s *Store) MineOwner(id string) (string, error) {
var vid sql.NullString
err := s.db.QueryRow(`SELECT viewer_id FROM pastes WHERE id = ? AND deleted_at IS NULL`, id).Scan(&vid)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
if !vid.Valid {
return "", nil
}
return vid.String, nil
}
func (s *Store) SoftDelete(id string) error {
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
return err
@@ -343,12 +390,14 @@ func (a *apiServer) routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(viewerCookieMiddleware)
// API
r.Route("/api", func(r chi.Router) {
r.Post("/pastes", a.handleCreatePaste)
r.Get("/pastes/{id}", a.handleGetPaste)
r.Delete("/pastes/{id}", a.handleDeletePaste)
r.Get("/mine", a.handleListMine)
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic)
r.Post("/guess-language", a.handleGuessLang)
@@ -368,6 +417,7 @@ func (a *apiServer) routes() http.Handler {
r.Get("/new", a.handleNewPage)
r.Get("/history", a.handleHistoryPage)
r.Get("/settings", a.handleSettingsPage)
r.Get("/mine", a.handleMinePage)
r.Handle("/static/*", staticHandler())
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
@@ -380,6 +430,45 @@ func (a *apiServer) routes() http.Handler {
return r
}
// viewerCookieMiddleware ensures every request carries an anonymous browser id
// cookie ("vwr"); sets one on the response if absent. Used by /mine (#37, #49).
func viewerCookieMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("vwr"); err != nil || c.Value == "" {
id := genSlug(16)
http.SetCookie(w, &http.Cookie{
Name: "vwr", Value: id, Path: "/",
MaxAge: 31536000, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
r.AddCookie(&http.Cookie{Name: "vwr", Value: id})
// remember that this cookie was minted here, not sent by the client
r = r.WithContext(context.WithValue(r.Context(), vwrMintedKey, true))
}
next.ServeHTTP(w, r)
})
}
type vwrMintedKeyType struct{}
var vwrMintedKey vwrMintedKeyType
func currentViewerID(r *http.Request) string {
if c, err := r.Cookie("vwr"); err == nil {
return c.Value
}
return ""
}
// viewerSentCookie reports whether the client itself sent a vwr cookie
// (as opposed to the middleware minting one for this request).
func viewerSentCookie(r *http.Request) bool {
if _, err := r.Cookie("vwr"); err != nil {
return false
}
_, minted := r.Context().Value(vwrMintedKey).(bool)
return !minted
}
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
setRateLimitHeaders(w, 1, 5)
if !rateLimitCreate(r) {
@@ -399,6 +488,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes))
return
}
p.ViewerID = currentViewerID(r)
created, err := a.store.CreatePaste(&p)
if err != nil {
writeErr(w, 400, err.Error())
@@ -463,6 +553,14 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "paste not found")
return
}
// viewer-cookie delete enforcement (#37): only the browser that created
// the paste (matching vwr) may delete it via this endpoint. Requests with
// no client-sent vwr cookie (plain API clients) are unaffected.
vid := currentViewerID(r)
if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid {
writeErr(w, 403, "not your paste")
return
}
if err := a.store.SoftDelete(row.ID); err != nil {
writeErr(w, 500, "db error")
return
@@ -470,6 +568,35 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
// handleListMine serves /api/mine: pastes created from this browser (#37).
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
vid := currentViewerID(r)
if vid == "" {
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
rows, total, err := a.store.ListMine(vid, limit, offset)
if err != nil {
writeErr(w, 500, "db error")
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": nullStrPtr(row.CustomSlug), "visibility": row.Visibility,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
}
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// doReq performs a request against the router, carrying the given cookies,
// and returns the recorder (so Set-Cookie from the viewer middleware is visible).
func doReq(t *testing.T, h http.Handler, method, path, cookie string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if cookie != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: cookie})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// viewerCookieFor performs a request without the vwr cookie and extracts the
// one the viewer middleware sets in the response.
func viewerCookieFor(t *testing.T, h http.Handler, path string) string {
t.Helper()
rec := doReq(t, h, "GET", path, "", "")
for _, c := range rec.Result().Cookies() {
if c.Name == "vwr" {
return c.Value
}
}
t.Fatal("vwr cookie not set")
return ""
}
func TestMineCreateListDelete(t *testing.T) {
globalLimiter = newLimiter() // fresh rate-limit buckets
webUI, err := NewWebUI()
if err != nil {
t.Fatal(err)
}
webUIInstance = webUI
store, err := OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
a := &apiServer{store: store, cfg: Config{MaxTextBytes: 5 * 1024 * 1024}}
h := a.routes()
alice := viewerCookieFor(t, h, "/history")
if alice == "" {
t.Fatal("no viewer cookie issued")
}
// create with alice's cookie -> stored viewer id
rec := doReq(t, h, "POST", "/api/pastes", alice, `{"content":"hello mine"}`)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID string }
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("no id returned")
}
// owner sees it in /api/mine
rec = doReq(t, h, "GET", "/api/mine", alice, "")
if rec.Code != 200 {
t.Fatalf("mine: %d", rec.Code)
}
var list struct {
Total int `json:"total"`
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 1 || len(list.Items) != 1 || list.Items[0].ID != created.ID {
t.Fatalf("mine list: total=%d items=%v", list.Total, list.Items)
}
// a different browser's cookie does NOT see it
bob := viewerCookieFor(t, h, "/history")
rec = doReq(t, h, "GET", "/api/mine", bob, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
t.Fatalf("other browser sees %d pastes, want 0", list.Total)
}
// delete enforcement: bob cannot delete alice's paste
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, bob, "")
if rec.Code != 403 {
t.Fatalf("bob delete: %d, want 403", rec.Code)
}
// owner can delete
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, alice, "")
if rec.Code != 200 {
t.Fatalf("alice delete: %d", rec.Code)
}
rec = doReq(t, h, "GET", "/api/mine", alice, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
t.Fatalf("after delete, mine total=%d, want 0", list.Total)
}
}
+4
View File
@@ -70,6 +70,10 @@ func (a *apiServer) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "settings.html", map[string]any{"Page": "settings"})
}
func (a *apiServer) handleMinePage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "mine.html", map[string]any{"Page": "mine"})
}
func agoString(ts int64) string {
s := time.Now().Unix() - ts
switch {
+3
View File
@@ -379,6 +379,9 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.settings-head { padding: 12px 18px; }
.settings-body { padding: 16px 18px; }
/* topbar: Git external-link arrow (#56) */
.topbar nav a .ext { width: 14px; height: 14px; margin-left: 4px; opacity: .55; vertical-align: -1px; }
@media (max-width: 640px) {
body { font-size: 16px; }
+2 -1
View File
@@ -17,7 +17,8 @@
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git</a>
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
</nav>
<div class="spacer"></div>
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
+89
View File
@@ -0,0 +1,89 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Saved pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th>Paste</th>
<th>Language</th>
<th>Size</th>
<th>Created</th>
<th>URL</th>
<th>ID</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function fmtSize(n) { if (n == null) return 'none'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; }
function ago(ts) {
const s = Math.floor(Date.now()/1000) - ts;
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
}
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
async function load() {
const res = await fetch('/api/mine');
const data = await res.json();
$('count').textContent = data.total.toLocaleString() + ' total';
const rows = $('rows');
if (!data.items.length) {
rows.innerHTML = '';
$('empty').style.display = 'block';
return;
}
$('empty').style.display = 'none';
rows.innerHTML = data.items.map(it =>
`<tr class="row" data-href="/${esc(it.id)}"><td><a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a>` +
(it.title ? `<div class="paste-sub">${esc(it.title)}</div>` : `<div class="paste-sub dim">none</div>`) + `</td>` +
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
`<td class="dim">${fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${esc(it.custom_slug)}">/${esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${esc(it.id)}">${esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`
).join('');
}
$('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (del) {
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
return;
}
const tr = e.target.closest('tr.row[data-href]');
if (!tr || e.target.closest('a')) return;
window.location.href = tr.dataset.href;
});
load();
</script>
{{template "foot" .}}