diff --git a/main.go b/main.go index 7c74ced..3b61cd8 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/mine_test.go b/mine_test.go new file mode 100644 index 0000000..538624f --- /dev/null +++ b/mine_test.go @@ -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) + } +} diff --git a/web.go b/web.go index 01498d3..52ac612 100644 --- a/web.go +++ b/web.go @@ -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 { diff --git a/web/static/app.css b/web/static/app.css index 89a7ec3..3bf5371 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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; } diff --git a/web/templates/layout.html b/web/templates/layout.html index e1d07ea..d63bb50 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -17,7 +17,8 @@
diff --git a/web/templates/mine.html b/web/templates/mine.html new file mode 100644 index 0000000..a933261 --- /dev/null +++ b/web/templates/mine.html @@ -0,0 +1,89 @@ +{{template "head" .}} +{{template "topbar" .}} +| Paste | +Language | +Size | +Created | +URL | +ID | +
|---|