My pastes page /mine with anonymous viewer cookie (#37)
- 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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user