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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -48,6 +49,7 @@ type Paste struct {
|
|||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||||
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
|
ViewerID string `json:"-"` // set from vwr cookie server-side (#37)
|
||||||
ViewCount int `json:"view_count"`
|
ViewCount int `json:"view_count"`
|
||||||
DeletionToken string `json:"-"`
|
DeletionToken string `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -69,6 +71,7 @@ type PasteRow struct {
|
|||||||
ViewCount int
|
ViewCount int
|
||||||
Size int
|
Size int
|
||||||
DeletionToken sql.NullString
|
DeletionToken sql.NullString
|
||||||
|
ViewerID sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type CanRow struct {
|
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 deletion_token TEXT`) // ignore if exists
|
||||||
|
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,9 +211,9 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|||||||
}
|
}
|
||||||
p.DeletionToken = genDeletionToken()
|
p.DeletionToken = genDeletionToken()
|
||||||
_, err := s.db.Exec(`INSERT INTO pastes
|
_, 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)
|
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken)
|
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -221,10 +225,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, 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)
|
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
|
||||||
var r PasteRow
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -256,6 +260,49 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
|||||||
return out, total, nil
|
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 {
|
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)
|
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
|
||||||
return err
|
return err
|
||||||
@@ -343,12 +390,14 @@ func (a *apiServer) routes() http.Handler {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
|
r.Use(viewerCookieMiddleware)
|
||||||
|
|
||||||
// API
|
// API
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Post("/pastes", a.handleCreatePaste)
|
r.Post("/pastes", a.handleCreatePaste)
|
||||||
r.Get("/pastes/{id}", a.handleGetPaste)
|
r.Get("/pastes/{id}", a.handleGetPaste)
|
||||||
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
||||||
|
r.Get("/mine", a.handleListMine)
|
||||||
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
||||||
r.Get("/public", a.handleListPublic)
|
r.Get("/public", a.handleListPublic)
|
||||||
r.Post("/guess-language", a.handleGuessLang)
|
r.Post("/guess-language", a.handleGuessLang)
|
||||||
@@ -368,6 +417,7 @@ func (a *apiServer) routes() http.Handler {
|
|||||||
r.Get("/new", a.handleNewPage)
|
r.Get("/new", a.handleNewPage)
|
||||||
r.Get("/history", a.handleHistoryPage)
|
r.Get("/history", a.handleHistoryPage)
|
||||||
r.Get("/settings", a.handleSettingsPage)
|
r.Get("/settings", a.handleSettingsPage)
|
||||||
|
r.Get("/mine", a.handleMinePage)
|
||||||
r.Handle("/static/*", staticHandler())
|
r.Handle("/static/*", staticHandler())
|
||||||
r.Get("/unlock/{id}", a.handlePasteView)
|
r.Get("/unlock/{id}", a.handlePasteView)
|
||||||
r.Post("/unlock/{id}", a.handlePasteView)
|
r.Post("/unlock/{id}", a.handlePasteView)
|
||||||
@@ -380,6 +430,45 @@ func (a *apiServer) routes() http.Handler {
|
|||||||
return r
|
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) {
|
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
||||||
setRateLimitHeaders(w, 1, 5)
|
setRateLimitHeaders(w, 1, 5)
|
||||||
if !rateLimitCreate(r) {
|
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))
|
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
p.ViewerID = currentViewerID(r)
|
||||||
created, err := a.store.CreatePaste(&p)
|
created, err := a.store.CreatePaste(&p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 400, err.Error())
|
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")
|
writeErr(w, 404, "paste not found")
|
||||||
return
|
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 {
|
if err := a.store.SoftDelete(row.ID); err != nil {
|
||||||
writeErr(w, 500, "db error")
|
writeErr(w, 500, "db error")
|
||||||
return
|
return
|
||||||
@@ -470,6 +568,35 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
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) {
|
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
|
|||||||
+109
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,10 @@ func (a *apiServer) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
renderPage(w, "settings.html", map[string]any{"Page": "settings"})
|
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 {
|
func agoString(ts int64) string {
|
||||||
s := time.Now().Unix() - ts
|
s := time.Now().Unix() - ts
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
|
|||||||
.settings-head { padding: 12px 18px; }
|
.settings-head { padding: 12px 18px; }
|
||||||
.settings-body { padding: 16px 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) {
|
@media (max-width: 640px) {
|
||||||
body { font-size: 16px; }
|
body { font-size: 16px; }
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
|
<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="/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>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
|
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
|
||||||
|
|||||||
@@ -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">×</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" .}}
|
||||||
Reference in New Issue
Block a user