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 {
|
||||
|
||||
+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"})
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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