33 Commits
Author SHA1 Message Date
poslop d5a47b1a31 Burn after N reads: reads_limit/reads_used, per-viewer 15min dedupe via paste_views, reads_remaining in API+stats pill, raw counts as read (#49)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-08 23:54:03 -05:00
poslop 127c12c79a Custom expiry: Custom radio with number+unit select, client-side validation 1min-1year, Go-duration composition (#48) 2026-09-08 23:54:03 -05:00
poslop 2e1ce508fa Saved page: live search + sortable columns via shared table module (#57)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- new web/static/table.js (PaletteTable): shared live search, client-side
  sort with indicators, pagination, row rendering + click-through
- history.html and mine.html both consume it; data endpoint, columns,
  empty-state text and row extras (delete buttons) are page-supplied
- verified in-browser: search, sort, delete on /mine; sort, pager on /history

Closes #57
2026-09-08 22:39:41 -05:00
poslop 76d7ac12e7 Small fixes: gutter alignment, paste column, sort arrows, copy feedback, stats bar, title slug (#50-#55)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- #50 gutter/code line misalignment: shared --code-lh/--code-fs tokens;
  .code .gutter drops its own vertical padding (was 14px vs 0, 14px offset)
- #51 history Paste column shows only the paste name, or slug pill when untitled
- #52 sort indicator arrow sits LEFT of the column label for all sortable columns
- #53 copy buttons give in-place success feedback ('Success!' in --ok for 2s):
  paste view copy button and new-page result copy button
- #54 collapsed stats summary bar: roomier padding (14px 18px) and item gap (16px);
  wraps gracefully on mobile
- #55 paste view title bar: slug pill only shown for custom slugs, id not repeated

Closes #50, closes #51, closes #52, closes #53, closes #54, closes #55
2026-09-08 22:35:43 -05:00
poslop 129934b645 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
2026-09-08 22:10:05 -05:00
poslop e83303a428 Consistency audit: shared input/button/heading tokens across pages (#18)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- One text-input treatment (size, color, placeholder) for .search input,
  .pw-field input, #title, #custom, .row text inputs
- Icon-only buttons (.iconbtn, .btn-icon) share .btn radius + focus ring
- Unified heading letter-spacing with .side-section h3 pattern
- Normalized settings card padding to the 12/18px wide-card scale
- Verified visually: before/after screenshots for new/history/settings/unlock

Closes #18
2026-09-08 22:00:21 -05:00
poslop acf71f7444 Palettes: pastel-lavender/peach/cloud presets + semantic status colors (#16)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
- Three new light [data-preset] blocks alongside midnight (default) and smooth
- --ok/--warn/--err semantic tokens with 4.5:1 contrast per preset
- .toast success/error variants wired in new.html
- ?theme= query param preview hook in layout head (screenshots only)
- Preview screenshots in docs/palette-previews/

Closes #16
2026-09-08 21:56:36 -05:00
poslop 9c4689e6de Mobile responsiveness: media query for 375-640px across topbar, deck, history, paste view, unlock (#45)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-08 21:45:42 -05:00
poslop ebcf7eec80 Live relative-time counters via shared [data-ts] ticker in layout foot (#46)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-08 21:41:29 -05:00
poslop b3112d2a35 History: sortable column headers with client-side sort (#42)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-08 21:36:53 -05:00
poslop 6a6f4d1587 History: URL + ID columns at end, custom_slug in /api/public (#44)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
2026-09-08 21:33:08 -05:00
poslop e31aa44ecb History: paste name under Paste column, drop Description column (#43)
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-08 21:32:10 -05:00
poslop 5b7198fb3c Settings gear in topbar linking to placeholder /settings page (#36)
CI / test (push) Successful in 20s
CI / docker (push) Skipped
- layout.html: inline SVG gear icon top-right, styled as .iconbtn.gear pill
- new settings.html template reusing standard layout with under-construction card
- register GET /settings route in main.go
2026-09-08 21:27:02 -05:00
poslop efa8c7145c Language detection: switch to go-enry with hint-seeded classifier (#41)
CI / test (push) Successful in 35s
CI / docker (push) Skipped
- Add github.com/go-enry/go-enry/v2 dependency to go.mod
- guessLang: JSON fast path, then enry strategies (shebangs, XML, modelines,
  content heuristics), then enry's Linguist-trained Bayesian classifier over
  regex-hint candidate languages; curated dropdown expanded with typescript,
  html, css, xml, php, ruby, perl, lua, dockerfile, toml, ini, diff, csharp
- enry display names normalized to lowercase stored ids (Dockerfile->dockerfile,
  Shell->bash, C#->csharp); unsupported languages render unhighlighted
- Tests: existing languages, all new languages, magic markers (enry shebang/
  DOCTYPE/FROM/diff handling), canonical mapping
2026-09-08 21:25:36 -05:00
poslop 7410b5c9cf Paste view: collapsible stats pill under title (#17)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-08 21:14:44 -05:00
poslop 3facff3d1e Syntax highlighting, rate limiting, creator auto-unlock (#1, #2, #26)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
#1: server-side regex highlighter (highlight.go) for go/python/js/json/bash/sql;
token span classes styled in app.css; per-line so gutter stays aligned.
#2: in-memory token-bucket rate limiter (ratelimit.go) on POST /api/pastes,
/api/guess-language and unlock POST; 429 + Retry-After + X-RateLimit headers.
#26: new-page JS POSTs the password to /{id} with ?next= after creation; the
unlock handler honors same-origin ?next= redirect so the creator lands on the
unlocked paste. POST /{id} route added.

Tests: ratelimit_test.go (burst/429, refill, unlock limit, highlight, auto-
unlock e2e); existing tests updated for per-test limiter isolation.
2026-09-08 21:09:25 -05:00
poslop 9d75d2f80d password reveal: slash through eye icon while password hidden
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:55:25 -05:00
poslop 6b21f997b5 gitignore: exclude binary and live SQLite WAL files 2026-09-08 20:53:26 -05:00
poslop 103a7a6af5 UI batch 1: copy toast, protection/custom-url spacing, pw reveal, SVG icons, unlock redesign, bfcache result reset, search spinner (#19-#25,#27,#28,#32)
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:52:25 -05:00
poslop 1f162c4003 sweeper: release custom URLs on expiry and after 30-day reservation (#29)
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:50:40 -05:00
poslop 0bbe65ce05 capitalization: fix placeholder casing (#12)
CI / test (push) Successful in 19s
CI / docker (push) Skipped
2026-09-08 20:29:37 -05:00
poslop db17bd20b0 UI polish: radius tokens, layered shadows, pill selections, default filenames, live search, hidden result card, em dash removal
CI / test (push) Successful in 17s
CI / docker (push) Skipped
Fixes #6 #7 #8 #9 #10 #11 #13 #14 #15
2026-09-08 20:28:13 -05:00
poslop f542ce0407 new: split editor into title bar card, body card, standalone create button
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 20:01:02 -05:00
poslop c32a6e406d UI: scale up another 1.5x, widen history page container to fit
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:54:35 -05:00
poslop ec8f75d80b UI: scale everything up ~15% for readability on smaller devices
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:51:31 -05:00
poslop efa551c566 new: language auto-detect - /api/guess-language, refresh button, guess on paste
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:41:11 -05:00
poslop 3e0e64dc4f history: pager as its own floating pill below the list
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:34:44 -05:00
poslop 4a467ca999 history: search box under title, Search placeholder
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:32:41 -05:00
poslop 026d9b8c92 topbar: New/Public/Git labels, git opens in new tab
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:28:40 -05:00
poslop 4d98a92b09 history: fix column alignment - scope .row flex rule to new page, colgroup widths, nowrap+ellipsis cells
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:26:07 -05:00
poslop 603b807c51 history: entire row clickable, hover accent on slug
CI / test (push) Successful in 17s
CI / docker (push) Skipped
2026-09-08 19:17:20 -05:00
poslop acafc13d20 test debian runner
CI / test (push) Successful in 1m12s
CI / docker (push) Skipped
2026-09-08 19:02:36 -05:00
poslop 176ef77737 CI: run jobs on debian-latest runner image
CI / test (push) Failing after 24s
CI / docker (push) Skipped
2026-09-08 18:57:27 -05:00
46 changed files with 2297 additions and 209 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: [debian-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -26,7 +26,7 @@ jobs:
# build & push image only on tags (releases) # build & push image only on tags (releases)
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
needs: test needs: test
runs-on: ubuntu-latest runs-on: [debian-latest]
env: env:
# dind sidecar listens on tcp; job containers reach it via the docker bridge gateway # dind sidecar listens on tcp; job containers reach it via the docker bridge gateway
DOCKER_HOST: tcp://172.17.0.1:2375 DOCKER_HOST: tcp://172.17.0.1:2375
+4 -4
View File
@@ -1,4 +1,4 @@
__pycache__/ palette
node_modules/ palette.db
*.log palette.db-shm
.DS_Store palette.db-wal
+56 -7
View File
@@ -3,8 +3,10 @@ package main
import ( import (
"crypto/rand" "crypto/rand"
"crypto/subtle" "crypto/subtle"
"database/sql"
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -16,14 +18,61 @@ func genDeletionToken() string {
return base64.RawURLEncoding.EncodeToString(b) return base64.RawURLEncoding.EncodeToString(b)
} }
// maybeBurn marks a paste soft-deleted if burn_after_read is set. // readWindowMinutes is the per-viewer dedupe window for burn-after-N-reads
// Returns true if this read consumed the paste. // (#49): the same viewer cookie returning within 15 minutes does not count
func (s *Store) maybeBurn(row *PasteRow) bool { // as a new read. See the decision comment on issue #49.
if !row.BurnAfterRead { const readWindowMinutes = 15
return false
// timeNow is overridable in tests to inject the clock.
var timeNow = time.Now
// registerRead applies the burn-after-read budget for one view (#49).
// For pastes with reads_limit set: the viewer's paste_views row is checked;
// a view within readWindowMinutes of the viewer's last view is deduped
// (count=false). Otherwise reads_used is incremented, and the paste is
// soft-deleted (burned) once reads_used reaches reads_limit. Viewers without
// a cookie (plain API clients) count as their own viewer id "".
// For legacy plain burn_after_read pastes (no reads_limit), any read burns.
// Returns the number of reads remaining (0 when burned), or nil when no
// budget is set. view_count is tracked separately and unaffected.
func (s *Store) registerRead(row *PasteRow, viewerID string) (remaining *int, count bool) {
if !row.ReadsLimit.Valid {
if row.BurnAfterRead {
s.SoftDelete(row.ID)
r := 0
return &r, true
}
return nil, false
} }
s.SoftDelete(row.ID) now := timeNow().Unix()
return true var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
row.ID, viewerID).Scan(&last)
if last.Valid && now-last.Int64 < readWindowMinutes*60 {
r := int(row.ReadsLimit.Int64) - row.ReadsUsed
if r < 0 {
r = 0
}
return &r, false
}
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
row.ID, viewerID, now)
used := row.ReadsUsed + 1
s.db.Exec(`UPDATE pastes SET reads_used=? WHERE id=?`, used, row.ID)
if int64(used) >= row.ReadsLimit.Int64 {
s.SoftDelete(row.ID)
}
r := int(row.ReadsLimit.Int64) - int(used)
if r < 0 {
r = 0
}
return &r, true
}
// burned reports whether a read-limited paste has exhausted its budget.
func (row *PasteRow) burned() bool {
return row.ReadsLimit.Valid && int64(row.ReadsUsed) >= row.ReadsLimit.Int64
} }
func deletionTokenEqual(stored, given string) bool { func deletionTokenEqual(stored, given string) bool {
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type anyHandler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
// createBurnReads creates a burn-after-N-reads paste and returns its id.
func createBurnReads(t *testing.T, h anyHandler, reads int) string {
t.Helper()
body, _ := json.Marshal(map[string]any{"content": "limited", "burn_after_read": true, "burn_after_reads": reads})
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create burn_after_reads=%d: %d %s", reads, rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("no id in create response")
}
return created.ID
}
func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
if viewer != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: viewer})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// viewer A: ok (read 1)
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1 (viewer A): %d %s", rec.Code, rec.Body.String())
}
// viewer B: ok (read 2)
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
t.Fatalf("read 2 (viewer B): %d %s", rec.Code, rec.Body.String())
}
// viewer C: burned -> 404
if rec := getWithCookie(t, h, id, "ccc"); rec.Code != 404 {
t.Fatalf("read 3 expected 404, got %d", rec.Code)
}
}
func TestBurnReadsSameViewerWithinWindowNoDecrement(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// same viewer reads twice within the window: second is deduped
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("deduped re-read expected 200, got %d", rec.Code)
}
// another viewer still gets read 2 (budget not consumed by re-reads)
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
t.Fatalf("read 2: %d", rec.Code)
}
}
func TestBurnReadsWindowExpiryRecounts(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
base := time.Now()
timeNow = func() time.Time { return base }
t.Cleanup(func() { timeNow = time.Now })
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
// 10 minutes later: still within window, deduped
timeNow = func() time.Time { return base.Add(10 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read within window: %d", rec.Code)
}
// 20 minutes after first read: window expired, counts as read 2
timeNow = func() time.Time { return base.Add(20 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read after window expected 200, got %d", rec.Code)
}
// budget exhausted -> 404 even for the same viewer
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 404 {
t.Fatalf("after budget expected 404, got %d", rec.Code)
}
}
func TestBurnReadsDefaultOne(t *testing.T) {
s := testServer(t)
h := s.routes()
// burn_after_read without burn_after_reads defaults to 1 read
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"one","burn_after_read":true}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 404 {
t.Fatalf("read 2 expected 404, got %d", rec.Code)
}
}
func TestBurnReadsPageViewCounts(t *testing.T) {
if webUIInstance == nil {
ui, err := NewWebUI()
if err != nil {
t.Fatal(err)
}
webUIInstance = ui
}
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// HTML page view counts as a read too (documented decision)
req := httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page view 1: %d", rec.Code)
}
// re-view within window: deduped
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page re-view: %d", rec.Code)
}
// distinct viewer: read 2, page renders with reads remaining
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "bbb"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page view 2: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Reads left") {
t.Fatal("stats pill missing 'Reads left'")
}
// third viewer: burned
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "ccc"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("page view 3 expected 404, got %d", rec.Code)
}
}
+2 -1
View File
@@ -47,8 +47,9 @@ func TestCustomSlugValidation(t *testing.T) {
{"bad slug", `{"content":"x","custom_slug":"has space"}`, 400}, {"bad slug", `{"content":"x","custom_slug":"has space"}`, 400},
{"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine {"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine
} }
for _, c := range cases { for i, c := range cases {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body))
req.RemoteAddr = "10.7.1." + string(rune('1'+i)) + ":1000" // avoid rate-limit bucket sharing
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != c.wantCode { if rec.Code != c.wantCode {
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+2
View File
@@ -4,12 +4,14 @@ go 1.27.1
require ( require (
github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/chi/v5 v5.3.2
github.com/go-enry/go-enry/v2 v2.9.6
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.39.0
modernc.org/sqlite v1.58.0 modernc.org/sqlite v1.58.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
+21
View File
@@ -1,7 +1,14 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs=
github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -12,8 +19,17 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
@@ -24,6 +40,11 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"encoding/json"
"net/http"
"regexp"
"strings"
"github.com/go-enry/go-enry/v2"
)
// hintRule is a lightweight regex hint that nudges detection. Hints don't
// decide on their own: matching hints are passed to enry's classifier as
// candidate languages, and enry (trained on Linguist samples) makes the final
// call. Adding a language later is usually a one-line addition here plus the
// dropdown in web/templates/new.html.
type hintRule struct {
lang string // enry language name
re *regexp.Regexp
}
// hintRules are evaluated in order; keep more specific languages earlier so
// ties break in their favor.
var hintRules = []hintRule{
{"Dockerfile", regexp.MustCompile(`(?mi)^(FROM\s+\S+:\S*|RUN\s+\S+|COPY\s+\S+\s+\S+|ENTRYPOINT\s+|WORKDIR\s+/)`)},
{"Diff", regexp.MustCompile(`(?m)^(diff --git|--- a/|\+\+\+ b/|@@ -\d+)`)},
{"PHP", regexp.MustCompile(`(?m)<\?php|\$\w+\s*=\s*[^=]|\becho\s+["'$]`)},
{"HTML", regexp.MustCompile(`(?i)<(!DOCTYPE|html|head|body|div|span|script|p|a)\b`)},
{"XML", regexp.MustCompile(`(?m)^<\?xml\b|<\/?[a-zA-Z][\w.-]*:[\w.-]*[>\s]`)},
{"CSS", regexp.MustCompile(`(?m)(^|\})\s*[^{}@]+\{[^}]*:[^}]*\}|@(media|import|font-face)\b`)},
{"TypeScript", regexp.MustCompile(`(?m)(:\s*(string|number|boolean|any)\b|\binterface \w+ \{|\btype \w+ =|\bimplements \w+)`)},
{"TOML", regexp.MustCompile(`(?m)^\[[\w."-]+\]\s*$|^\w[\w-]*\s*=\s*("[^"]*"|\d+|true|false|\[[^\]]*\])\s*$`)},
{"INI", regexp.MustCompile(`(?m)^\[[\w.-]+\]\s*$|^\w[\w.-]*\s*=\s*\S+\s*$`)},
{"Ruby", regexp.MustCompile(`(?m)(\bdef \w+($|\s)|\brequire ['"]|\bputs \w+|@\w+\s*=\s*[^=]|\bend\b\s*$)`)},
{"Perl", regexp.MustCompile(`(?m)(\buse strict\b|\bmy \$\w+|->\{|sub \w+ \{)`)},
{"Lua", regexp.MustCompile(`(?m)(\bfunction\s+\w+\s*\(|\blocal \w+\s*=|\bthen\b|\belseif\b|\.\.\.)`)},
{"Go", regexp.MustCompile(`(?m)^\s*(package \w+|import \(|func (\w+|\() )`)},
{"Python", regexp.MustCompile(`(?m)^\s*(def \w+|class \w+|import \w+|from \w+ import |@\w+)`)},
{"JavaScript", regexp.MustCompile(`(?m)(\bconst \w+ = |require\(|import \w+ from |=> \{|\bconsole\.log\()` )},
{"Rust", regexp.MustCompile(`(?m)(\bfn \w+|let mut \b|\bimpl \b|use std::)`)},
{"Java", regexp.MustCompile(`(?m)(\bpublic (static |final |class )|\bSystem\.out\.print|import java\.)`)},
{"C", regexp.MustCompile(`(?m)(#include\s*<\w+\.h>|printf\(|\bint main\()` )},
{"C++", regexp.MustCompile(`(?m)(#include\s*<(iostream|vector|string)>|std::|\bcout\s*<<)`)},
{"SQL", regexp.MustCompile(`(?i)\b(SELECT .+ FROM|INSERT INTO|CREATE TABLE|UPDATE \w+ SET)\b`)},
{"YAML", regexp.MustCompile(`(?m)^(\w[\w-]*:\s*(\||\S)| \w[\w-]*: |---\s*$)`)},
{"Markdown", regexp.MustCompile("(?m)^(#{1,6} \\S|\\|.*\\||-\\s\\[\\s?\\]|```)")},
{"Shell", regexp.MustCompile(`(?m)^(#!.*bash|#!.*sh|\w+\(\)\s*\{)` )},
}
// canonical maps enry display names to the lowercase ids we store and render.
var canonical = map[string]string{
"Dockerfile": "dockerfile",
"C#": "csharp",
"Shell": "bash",
}
// guessLang detects a language from pasted content. Order: fast decisive
// paths (empty, JSON, unambiguous markers enry can't see without a filename),
// then enry strategies (shebangs, XML decl, modelines, content heuristics),
// then enry's classifier seeded by our regex hints.
func guessLang(s string) string {
src := strings.TrimSpace(s)
if src == "" {
return ""
}
// JSON: must start with { or [ and parse — cheaper and more decisive
// than the classifier for pasted JSON, and handles compact single-line
// JSON that content heuristics miss.
if src[0] == '{' || src[0] == '[' {
var v any
if json.Unmarshal([]byte(src), &v) == nil {
return "json"
}
}
// enry's built-in strategies: shebangs, XML declaration, modelines,
// content heuristics.
if lang := enry.GetLanguage("", []byte(src)); lang != "" && lang != enry.OtherLanguage {
return normalizeLang(lang)
}
// collect hint-matched languages as classifier candidates
cands := []string{}
for _, h := range hintRules {
if h.re.MatchString(src) {
cands = append(cands, h.lang)
}
}
if len(cands) > 0 {
// enry's Bayesian classifier (trained on Linguist samples) picks the
// best of the hint candidates; fall back to the first hint if it
// can't decide.
if lang, _ := enry.GetLanguageByClassifier([]byte(src), cands); lang != "" {
return normalizeLang(lang)
}
return normalizeLang(cands[0])
}
return "text"
}
// normalizeLang maps enry display names to our lowercase stored ids.
func normalizeLang(lang string) string {
if c, ok := canonical[lang]; ok {
return c
}
return strings.ToLower(lang)
}
func (a *apiServer) handleGuessLang(w http.ResponseWriter, r *http.Request) {
setRateLimitHeaders(w, 1, 5)
if !rateLimitGuess(r) {
writeRateLimited(w, 1)
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json body")
return
}
lang := guessLang(req.Content)
writeJSON(w, http.StatusOK, map[string]any{"language": lang})
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"strings"
"testing"
)
// TestGuessLangExisting covers languages detected before the enry switch and
// still expected to work after it.
func TestGuessLangExisting(t *testing.T) {
cases := map[string]string{
"package main\n\nfunc main() {}\n": "go",
"def foo():\n return 1\n": "python",
"const x = 1;\nconsole.log(x);\n": "javascript",
"{\"a\": 1, \"b\": [2, 3]}\n": "json",
"hello world just some text": "text",
"": "",
"fn main() {\n let x = 1;\n}\n": "rust",
"#include <stdio.h>\nint main() { printf(\"hi\"); }\n": "c",
"SELECT id, name FROM users WHERE active = 1;\n": "sql",
"title: demo\nitems:\n - one\n - two\n": "yaml",
"# Demo\n\nsome *markdown* text with a [link](http://x)\n": "markdown",
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
}
for src, want := range cases {
if got := guessLang(src); got != want {
t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
}
}
}
// TestGuessLangNewLanguages covers the languages added to the dropdown as part
// of the enry integration (#41).
func TestGuessLangNewLanguages(t *testing.T) {
cases := map[string]string{
"interface User {\n name: string;\n age: number;\n}\n": "typescript",
"<!DOCTYPE html>\n<html>\n<head><title>hi</title></head>\n</html>\n": "html",
".container {\n display: flex;\n padding: 4px;\n}\n": "css",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root><item>x</item></root>\n": "xml",
"<?php\nfunction hi() { echo 'x'; }\n": "php",
"def greet(name)\n puts \"hi #{name}\"\nend\n": "ruby",
"use strict;\nmy $x = 5;\nprint \"x is $x\\n\";\n": "perl",
"local x = 10\nfunction add(a, b)\n return a + b\nend\n": "lua",
"FROM golang:1.22\nRUN go build -o app .\nCMD [\"./app\"]\n": "dockerfile",
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n": "toml",
"[server]\nhost = 127.0.0.1\nport = 8080\n": "ini",
"diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1 +1 @@\n": "diff",
}
for src, want := range cases {
if got := guessLang(src); got != want {
t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
}
}
}
// TestGuessLangMagicMarkers verifies enry's built-in shebang / signature
// handling that replaced the hand-rolled magic-marker pre-checks (#41).
func TestGuessLangMagicMarkers(t *testing.T) {
cases := map[string]string{
"#!/usr/bin/env node\nconsole.log('hi');\n": "javascript",
"#!/usr/bin/env python3\nimport sys\nprint(sys.argv)\n": "python",
"#!/usr/bin/python\nprint('x')\n": "python",
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
"#!/bin/sh\necho hi\n": "bash",
"<?php\necho 'x';\n": "php",
"<!DOCTYPE html>\n<html><body></body></html>": "html",
"FROM alpine:3.19\nCOPY app /app\n": "dockerfile",
"FROM ubuntu:24.04\nRUN apt-get update\n": "dockerfile",
"diff --git a/x.txt b/x.txt\nindex 123..456 100644\n": "diff",
"--- a/config.yml\n+++ b/config.yml\n@@ -1,2 +1,3 @@\n": "diff",
}
for src, want := range cases {
if got := guessLang(src); got != want {
t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
}
}
}
// TestGuessLangCanonical verifies enry display names are mapped/lowercased to
// our stored ids.
func TestGuessLangCanonical(t *testing.T) {
if got := guessLang("FROM debian:12\nCMD [\"sh\"]\n"); got != "dockerfile" {
t.Errorf("Dockerfile canonical mapping failed: got %q", got)
}
if got := guessLang("#!/bin/sh\necho hi\n"); got != "bash" {
t.Errorf("Shell canonical mapping failed: got %q", got)
}
// uncurated languages still come back lowercase
if got := guessLang("<h1>{{.Name}}</h1>\n"); got != strings.ToLower(got) {
t.Errorf("expected lowercase output, got %q", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"html"
"regexp"
"strings"
)
// Minimal regex-based syntax highlighter for the paste view (#1).
// Server-side, no external dependencies. Tokens: comments, strings,
// numbers, keywords. Output is HTML with span classes styled in app.css.
// Highlighting is applied per line so the gutter stays line-aligned.
type hlLang struct {
keywords map[string]bool
lineComps []string // line comment prefixes
blockCom [2]string
}
var hlLangs = map[string]hlLang{
"go": {
keywords: set("break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false string int int64 int32 uint byte rune bool float64 float32 error make new len cap append panic recover"),
lineComps: []string{"//"},
blockCom: [2]string{"/*", "*/"},
},
"python": {
keywords: set("and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield self print len range str int float list dict set tuple open"),
lineComps: []string{"#"},
},
"javascript": {
keywords: set("async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new null of return static super switch this throw true false try typeof undefined var void while with yield console log document window Math JSON Array Object String Number Boolean Promise"),
lineComps: []string{"//"},
blockCom: [2]string{"/*", "*/"},
},
"json": {
keywords: set("true false null"),
},
"bash": {
keywords: set("if then else elif fi for while do done case esac function return exit local export echo cd ls grep awk sed cat curl sudo apt git make echo read shift set unset trap source alias printf test rm mv cp mkdir chmod chown"),
lineComps: []string{"#"},
},
"sql": {
keywords: set("SELECT FROM WHERE INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE DROP ALTER INDEX JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER HAVING LIMIT OFFSET AND OR NOT NULL IS IN AS DISTINCT UNION ALL PRIMARY KEY FOREIGN REFERENCES DEFAULT UNIQUE CHECK VIEW WITH RETURNING EXISTS CASE WHEN THEN ELSE END COUNT SUM AVG MIN MAX"),
lineComps: []string{"--"},
blockCom: [2]string{"/*", "*/"},
},
}
// aliases from the language dropdown / guesser
var hlAliases = map[string]string{
"py": "python", "python3": "python",
"js": "javascript", "node": "javascript", "typescript": "javascript", "ts": "javascript",
"sh": "bash", "shell": "bash", "zsh": "bash",
"golang": "go",
"c": "go", "cpp": "go", "c++": "go", "java": "go", "rust": "go", "rs": "go",
// C-family shares the same token rules as Go for highlighting purposes
}
func set(words string) map[string]bool {
m := make(map[string]bool)
for _, w := range strings.Fields(words) {
m[w] = true
}
return m
}
func resolveLang(lang string) (string, hlLang, bool) {
l := strings.ToLower(strings.TrimSpace(lang))
if l == "" || l == "text" || l == "markdown" || l == "yaml" {
return "", hlLang{}, false
}
if l == "yml" {
return "", hlLang{}, false
}
if g, ok := hlAliases[l]; ok {
if h, ok2 := hlLangs[g]; ok2 {
return g, h, true
}
return "", hlLang{}, false
}
h, ok := hlLangs[l]
return l, h, ok
}
var hlTokenRe = regexp.MustCompile(`("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|` + "`" + `[^` + "`" + `]*` + "`" + `?|//[^\n]*|--[^\n]*|#[^\n]*|/\*.*?(?:\*/|$)|\b(?:[0-9]+\.?[0-9]*|0x[0-9a-fA-F]+)\b|[A-Za-z_][A-Za-z0-9_]*)`)
func highlightLine(line string, h hlLang, lang string) string {
var b strings.Builder
rest := line
// strip a trailing block-comment opener handled below; regex covers it
for {
loc := hlTokenRe.FindStringIndex(rest)
if loc == nil {
b.WriteString(html.EscapeString(rest))
break
}
b.WriteString(html.EscapeString(rest[:loc[0]]))
tok := rest[loc[0]:loc[1]]
cls := ""
switch {
case strings.HasPrefix(tok, "//") || strings.HasPrefix(tok, "#") ||
strings.HasPrefix(tok, "--") || strings.HasPrefix(tok, "/*"):
// '#/--' are comments only in langs that use them
if (strings.HasPrefix(tok, "#") && !containsStr(h.lineComps, "#")) ||
(strings.HasPrefix(tok, "--") && !containsStr(h.lineComps, "--")) {
cls = ""
} else {
cls = "tok-com"
}
case strings.HasPrefix(tok, "\"") || strings.HasPrefix(tok, "'") || strings.HasPrefix(tok, "`"):
cls = "tok-str"
case tok[0] >= '0' && tok[0] <= '9':
cls = "tok-num"
case h.keywords[tok]:
cls = "tok-kw"
}
if cls != "" {
b.WriteString(`<span class="` + cls + `">` + html.EscapeString(tok) + `</span>`)
} else {
b.WriteString(html.EscapeString(tok))
}
rest = rest[loc[1]:]
}
return b.String()
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// highlightCode returns HTML with highlighting spans; safe because all
// non-token text is html-escaped.
func highlightCode(content, lang string) string {
l, h, ok := resolveLang(lang)
_ = l
if !ok {
return html.EscapeString(content)
}
lines := strings.Split(content, "\n")
out := make([]string, len(lines))
for i, line := range lines {
out[i] = highlightLine(line, h, lang)
}
return strings.Join(out, "\n")
}
+200 -6
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"database/sql" "database/sql"
"embed" "embed"
"encoding/json" "encoding/json"
@@ -23,6 +24,7 @@ var webFS embed.FS
const ( const (
softDeleteGraceDays = 7 softDeleteGraceDays = 7
customSlugReservationDays = 30
) )
type Config struct { type Config struct {
@@ -42,11 +44,14 @@ type Paste struct {
Password *string `json:"password,omitempty"` Password *string `json:"password,omitempty"`
ExpiresIn *string `json:"expires_in,omitempty"` ExpiresIn *string `json:"expires_in,omitempty"`
BurnAfterRead bool `json:"burn_after_read,omitempty"` BurnAfterRead bool `json:"burn_after_read,omitempty"`
BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1)
Visibility string `json:"visibility"` Visibility string `json:"visibility"`
CanID *string `json:"can_id,omitempty"` CanID *string `json:"can_id,omitempty"`
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)
readsLimit *int64 // #49: resolved read budget, not serialized
ViewCount int `json:"view_count"` ViewCount int `json:"view_count"`
DeletionToken string `json:"-"` DeletionToken string `json:"-"`
} }
@@ -61,6 +66,8 @@ type PasteRow struct {
PasswordHash sql.NullString PasswordHash sql.NullString
ExpiresAt sql.NullInt64 ExpiresAt sql.NullInt64
BurnAfterRead bool BurnAfterRead bool
ReadsLimit sql.NullInt64
ReadsUsed int
Visibility string Visibility string
CanID sql.NullString CanID sql.NullString
CreatedAt int64 CreatedAt int64
@@ -68,6 +75,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 {
@@ -130,6 +138,15 @@ 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)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
paste_id TEXT NOT NULL,
viewer_id TEXT NOT NULL,
last_viewed INTEGER NOT NULL,
PRIMARY KEY (paste_id, viewer_id)
)`) // #49: per-viewer read dedupe window
return err return err
} }
@@ -187,6 +204,15 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
} }
} }
// #49: burn-after-read pastes carry a read budget (default 1 read)
if p.BurnAfterRead {
limit := int64(1)
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
limit = int64(*p.BurnAfterReads)
}
p.readsLimit = &limit
}
visibility := p.Visibility visibility := p.Visibility
if visibility == "" { if visibility == "" {
visibility = "public" visibility = "public"
@@ -206,9 +232,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, reads_limit)
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, p.readsLimit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -220,10 +246,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, reads_limit, COALESCE(reads_used, 0)
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, &r.ReadsLimit, &r.ReadsUsed)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -255,6 +281,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
@@ -272,11 +341,34 @@ func (s *Store) SweepExpired() {
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace) s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
} }
// ReleaseCustomSlugs frees custom URLs so they can be reused:
// - pastes whose expires_at has passed (expired or soft-deleted/expired),
// - pastes created more than 30 days ago (custom URLs are a reservation, not permanent).
//
// It returns the number of pastes whose custom_slug was released.
func (s *Store) ReleaseCustomSlugs() (int64, error) {
now := time.Now().Unix()
res, err := s.db.Exec(`UPDATE pastes SET custom_slug = NULL
WHERE custom_slug IS NOT NULL
AND (expires_at IS NOT NULL AND expires_at > 0 AND expires_at < ?
OR created_at < ?)`,
now, now-customSlugReservationDays*86400)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
if n > 0 {
log.Printf("released %d custom slug(s)", n)
}
return n, nil
}
func (s *Store) StartSweeper(every time.Duration) { func (s *Store) StartSweeper(every time.Duration) {
go func() { go func() {
t := time.NewTicker(every) t := time.NewTicker(every)
for range t.C { for range t.C {
s.SweepExpired() s.SweepExpired()
s.ReleaseCustomSlugs()
} }
}() }()
} }
@@ -319,14 +411,17 @@ 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("/pastes/can", a.handleCreateCan) r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan) r.Get("/cans/{id}", a.handleGetCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem) r.Get("/cans/{id}/items/{item}", a.handleCanItem)
@@ -342,10 +437,13 @@ func (a *apiServer) routes() http.Handler {
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP) r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
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("/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)
r.Get("/{id}", a.handlePasteView) r.Get("/{id}", a.handlePasteView)
r.Post("/{id}", a.handlePasteView)
r.NotFound(func(w http.ResponseWriter, r *http.Request) { r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not found") writeErr(w, 404, "not found")
@@ -353,7 +451,51 @@ 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)
if !rateLimitCreate(r) {
writeRateLimited(w, 1)
return
}
var p Paste var p Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeErr(w, 400, "invalid json body") writeErr(w, 400, "invalid json body")
@@ -367,6 +509,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())
@@ -380,6 +523,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
"api_url": "/api/pastes/" + created.ID, "api_url": "/api/pastes/" + created.ID,
"expires_at": created.ExpiresAt, "expires_at": created.ExpiresAt,
"created_at": created.CreatedAt, "created_at": created.CreatedAt,
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
}) })
} }
@@ -398,6 +542,10 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "paste expired") writeErr(w, 404, "paste expired")
return return
} }
if row.burned() { // #49: read budget exhausted
writeErr(w, 404, "paste not found")
return
}
if row.PasswordHash.Valid { if row.PasswordHash.Valid {
// require password via header or query // require password via header or query
pw := r.Header.Get("X-Paste-Password") pw := r.Header.Get("X-Paste-Password")
@@ -415,11 +563,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
} }
return nil return nil
} }
a.store.maybeBurn(row) rem, _ := a.store.registerRead(row, currentViewerID(r)) // #49 (also covers legacy burn)
writeJSON(w, 200, map[string]any{ writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType, "id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt, "language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt,
"view_count": row.ViewCount, "visibility": row.Visibility, "view_count": row.ViewCount, "visibility": row.Visibility,
"reads_remaining": rem,
}) })
} }
@@ -430,6 +579,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
@@ -437,6 +594,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 {
@@ -454,6 +640,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
items = append(items, map[string]any{ items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang, "id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": nullStrPtr(row.CustomSlug),
}) })
} }
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -474,6 +661,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
http.Error(w, "password required", 401) http.Error(w, "password required", 401)
return return
} }
if row.burned() { // #49: read budget exhausted
http.Error(w, "not found", 404)
return
}
// #49 decision: raw reads count against the read budget too, with the
// same per-viewer 15-minute dedupe window as page views.
a.store.registerRead(row, currentViewerID(r))
w.Header().Set("Content-Type", row.ContentType) w.Header().Set("Content-Type", row.ContentType)
a.store.IncrementViews(row.ID) a.store.IncrementViews(row.ID)
w.Write([]byte(row.Content)) w.Write([]byte(row.Content))
+1
View File
@@ -11,6 +11,7 @@ import (
func testServer(t *testing.T) *apiServer { func testServer(t *testing.T) *apiServer {
t.Helper() t.Helper()
globalLimiter = newLimiter() // fresh buckets per test
store, err := OpenStore(":memory:") store, err := OpenStore(":memory:")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+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)
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Per-IP token bucket rate limiting (#2). Goroutine-safe via mutex.
type bucket struct {
tokens float64
last time.Time
rate float64 // tokens per second
burst float64
}
type limiter struct {
mu sync.Mutex
buckets map[string]*bucket
}
func newLimiter() *limiter {
return &limiter{buckets: make(map[string]*bucket)}
}
func (l *limiter) allow(key string, rate, burst float64) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: burst, last: now, rate: rate, burst: burst}
l.buckets[key] = b
}
elapsed := now.Sub(b.last).Seconds()
b.tokens += elapsed * b.rate
if b.tokens > b.burst {
b.tokens = b.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// clientIP extracts the request IP (no reverse proxy header by default).
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter()
// rateLimitCreate: 1 req/sec refill, burst 5, per IP.
func rateLimitCreate(r *http.Request) bool {
return globalLimiter.allow("create:"+clientIP(r), 1, 5)
}
// rateLimitGuess: 1 req/sec refill, burst 5, per IP.
func rateLimitGuess(r *http.Request) bool {
return globalLimiter.allow("guess:"+clientIP(r), 1, 5)
}
// rateLimitUnlock: 5 per minute per IP+paste.
func rateLimitUnlock(id string, r *http.Request) bool {
return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
}
// writeRateLimited responds 429 with Retry-After based on refill rate.
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
writeErr(w, 429, "rate limit exceeded")
}
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
func setRateLimitHeaders(w http.ResponseWriter, limit, burst int) {
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
w.Header().Set("X-RateLimit-Burst", strconv.Itoa(burst))
}
+230
View File
@@ -0,0 +1,230 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newTestServer(t *testing.T) *apiServer {
t.Helper()
globalLimiter = newLimiter() // fresh buckets per test
store, err := OpenStore(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
if webUIInstance == nil {
ui, err := NewWebUI()
if err != nil {
t.Fatal(err)
}
webUIInstance = ui
}
return &apiServer{store: store, cfg: Config{MaxTextBytes: 1024 * 1024}}
}
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
// TestRateLimitCreateBurst: burst of 5 creates allowed, then 429.
func TestRateLimitCreateBurst(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
// unique IP per test run so tests don't share buckets
reqIP := "10.9.9.1:1234"
for i := 0; i < 5; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("req %d: want 201, got %d: %s", i, rr.Code, rr.Body.String())
}
}
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("6th req: want 429, got %d", rr.Code)
}
if ra := rr.Header().Get("Retry-After"); ra == "" {
t.Fatal("missing Retry-After header")
}
if ra := rr.Header().Get("X-RateLimit-Limit"); ra == "" {
t.Fatal("missing X-RateLimit-Limit header")
}
}
// TestRateLimitRefill: after waiting >1s a token refills and a create succeeds.
func TestRateLimitRefill(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.9.9.2:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
}
time.Sleep(1100 * time.Millisecond)
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("after refill: want 201, got %d", rr.Code)
}
}
// TestRateLimitGuess: guess-language endpoint is limited too.
func TestRateLimitGuess(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.9.9.3:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"def f(): pass"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if i < 5 && rr.Code != 200 {
t.Fatalf("req %d: want 200, got %d", i, rr.Code)
}
}
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"x"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("want 429, got %d", rr.Code)
}
}
// TestRateLimitUnlock: 5 unlock attempts per IP+paste per minute, then 429.
func TestRateLimitUnlock(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
// create a password-protected paste
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret", "password": "pw1", "visibility": "unlisted"})
if rr.Code != 201 {
t.Fatalf("create failed: %d", rr.Code)
}
var created map[string]any
json.Unmarshal(rr.Body.Bytes(), &created)
id := created["id"].(string)
reqIP := "10.9.9.4:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = reqIP
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if i < 5 && rr2.Code == 429 {
t.Fatalf("req %d: unexpected 429", i)
}
}
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = reqIP
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if rr2.Code != 429 {
t.Fatalf("want 429, got %d", rr2.Code)
}
}
// TestHighlightCode basic expectations.
func TestHighlightCode(t *testing.T) {
in := "func main() {\n\t// comment\n\tfmt.Println(\"hello\")\n}\n"
out := highlightCode(in, "go")
if !bytes.Contains([]byte(out), []byte(`<span class="tok-kw">func</span>`)) {
t.Fatalf("no keyword span: %s", out)
}
if !bytes.Contains([]byte(out), []byte(`<span class="tok-com">// comment</span>`)) {
t.Fatalf("no comment span: %s", out)
}
if !bytes.Contains([]byte(out), []byte(`tok-str">&#34;hello&#34;</span>`)) {
t.Fatalf("no string span: %s", out)
}
// unsupported language returns escaped plain text
plain := highlightCode("<b>x</b>", "text")
if plain != "&lt;b&gt;x&lt;/b&gt;" {
t.Fatalf("plain escaping wrong: %q", plain)
}
// line count preserved (gutter alignment)
if got := len(splitLines(highlightCode("a\nb\nc", "go"))); got != 3 {
t.Fatalf("want 3 lines, got %d", got)
}
}
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
// TestCreatorAutoUnlock: create with password, then POST the password to
// /{id}, then GET /{id} with the cookie shows the paste (#26).
func TestCreatorAutoUnlock(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret stuff", "password": "pw2", "visibility": "unlisted"})
if rr.Code != 201 {
t.Fatalf("create failed: %d", rr.Code)
}
var created map[string]any
json.Unmarshal(rr.Body.Bytes(), &created)
id := created["id"].(string)
// locked GET shows unlock page
req := httptest.NewRequest("GET", "/"+id, nil)
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if bytes.Contains(rr2.Body.Bytes(), []byte("secret stuff")) {
t.Fatal("locked paste leaked content")
}
// unlock POST with ?next= should set cookie and redirect
req = httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=pw2&next=/"+id+"?created=1")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr3 := httptest.NewRecorder()
h.ServeHTTP(rr3, req)
if rr3.Code != http.StatusSeeOther {
t.Fatalf("unlock POST: want 303, got %d", rr3.Code)
}
var cookie *http.Cookie
for _, c := range rr3.Result().Cookies() {
if c.Name == "pw_"+id {
cookie = c
}
}
if cookie == nil {
t.Fatal("no pw_ cookie set")
}
// GET with cookie shows content
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(cookie)
rr4 := httptest.NewRecorder()
h.ServeHTTP(rr4, req)
if !bytes.Contains(rr4.Body.Bytes(), []byte("secret stuff")) {
t.Fatalf("cookie unlock failed: %d %s", rr4.Code, rr4.Body.String())
}
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"testing"
"time"
)
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
// created_at/expires_at, bypassing the API's timestamp handling.
func insertPasteWithSlug(t *testing.T, s *Store, slug string, createdAt, expiresAt int64) string {
t.Helper()
id := genSlug(6)
_, err := s.db.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt)
if err != nil {
t.Fatal(err)
}
return id
}
func strPtr(s string) *string { return &s }
func TestReleaseSlugOnExpiredPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("release-notes"); taken {
t.Fatal("slug should be released after expiry")
}
// slug must be reusable by a new paste
p, err := s.store.CreatePaste(&Paste{Content: "new", CustomSlug: strPtr("release-notes")})
if err != nil {
t.Fatalf("reuse slug: %v", err)
}
if p.CustomSlug == nil || *p.CustomSlug != "release-notes" {
t.Fatal("new paste did not claim released slug")
}
}
func TestReleaseSlugOnOldPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
// created 31 days ago, no expiry -> released by 30-day reservation rule
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("old-url"); taken {
t.Fatal("slug should be released after 30-day reservation")
}
}
func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 0 {
t.Fatalf("released %d err %v, want 0", n, err)
}
for _, slug := range []string{"fresh-url", "fresh-url2"} {
if taken, _ := s.store.SlugTaken(slug); !taken {
t.Fatalf("slug %q should still be held", slug)
}
}
}
func TestSweeperTickerReleasesSlugs(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
s.store.StartSweeper(10 * time.Millisecond)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("ticker did not release slug in time")
}
+46 -11
View File
@@ -66,6 +66,14 @@ func (a *apiServer) handleHistoryPage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "history.html", map[string]any{"Page": "history"}) renderPage(w, "history.html", map[string]any{"Page": "history"})
} }
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 { func agoString(ts int64) string {
s := time.Now().Unix() - ts s := time.Now().Unix() - ts
switch { switch {
@@ -92,7 +100,7 @@ func expiryString(expiresAt int64) string {
} }
} }
func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string) { func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
lines := strings.Count(row.Content, "\n") + 1 lines := strings.Count(row.Content, "\n") + 1
gutter := "" gutter := ""
for i := 1; i <= lines; i++ { for i := 1; i <= lines; i++ {
@@ -102,22 +110,36 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
if row.ExpiresAt.Valid { if row.ExpiresAt.Valid {
expIn = expiryString(row.ExpiresAt.Int64) expIn = expiryString(row.ExpiresAt.Int64)
} }
lang := row.Language.String
if lang == "" {
lang = "text"
}
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt))
data := map[string]any{ data := map[string]any{
"Page": "paste", "Page": "paste",
"ID": row.ID, "ID": row.ID,
"Title": row.Title.String, "Title": row.Title.String,
"Language": row.Language.String, "Language": row.Language.String,
"ContentHTML": template.HTMLEscapeString(row.Content), "StatsSummary": summary,
"SizeHuman": humanSize(len(row.Content)),
"HasPassword": row.PasswordHash.Valid,
"BurnAfterRead": row.BurnAfterRead,
"CustomSlug": row.CustomSlug.String,
"ContentHTML": template.HTML(highlightCode(row.Content, row.Language.String)), // safe: highlightCode escapes all non-span text
"ContentAttr": row.Content, "ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"), "Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines, "LineCount": lines,
"SizeBytes": len(row.Content), "SizeBytes": len(row.Content),
"CreatedAgo": agoString(row.CreatedAt), "CreatedAgo": agoString(row.CreatedAt),
"CreatedAtUnix": row.CreatedAt,
"ViewCount": row.ViewCount, "ViewCount": row.ViewCount,
"Visibility": row.Visibility, "Visibility": row.Visibility,
"ExpiresAt": row.ExpiresAt.Valid, "ExpiresAt": row.ExpiresAt.Valid,
"ExpiresIn": expIn, "ExpiresIn": expIn,
"DeletionToken": deletionToken, "DeletionToken": deletionToken,
"ReadsLimit": row.ReadsLimit.Valid,
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
"ReadsTotal": int(row.ReadsLimit.Int64),
"JustCreated": justCreated, "JustCreated": justCreated,
"Host": "this host", "Host": "this host",
} }
@@ -143,6 +165,10 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
if row.PasswordHash.Valid { if row.PasswordHash.Valid {
// if a password was submitted via unlock form, verify and set cookie for this paste // if a password was submitted via unlock form, verify and set cookie for this paste
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if !rateLimitUnlock(row.ID, r) {
writeRateLimited(w, 60)
return
}
r.ParseForm() r.ParseForm()
pw := r.FormValue("password") pw := r.FormValue("password")
if pw != "" && checkPassword(row.PasswordHash.String, pw) { if pw != "" && checkPassword(row.PasswordHash.String, pw) {
@@ -150,34 +176,43 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
Name: "pw_" + row.ID, Value: "1", Path: "/", Name: "pw_" + row.ID, Value: "1", Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
}) })
// re-render without lock // re-render without lock, or redirect if ?next= was given (#26)
a.renderPaste(w, row, false, "") if next := r.FormValue("next"); next != "" {
// only allow same-origin relative paths
if len(next) > 0 && next[0] == '/' && !strings.HasPrefix(next, "//") {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
}
a.renderPaste(w, row, false, "", nil)
return return
} }
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt)}) renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
return return
} }
// check cookie // check cookie
c, err := r.Cookie("pw_" + row.ID) c, err := r.Cookie("pw_" + row.ID)
if err != nil || c.Value != "1" { if err != nil || c.Value != "1" {
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt)}) renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
return return
} }
} }
a.store.IncrementViews(row.ID)
justCreated := r.URL.Query().Get("created") == "1" justCreated := r.URL.Query().Get("created") == "1"
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if justCreated && token != "" { if justCreated && token != "" {
// one-time display of the deletion token via the created banner // one-time display of the deletion token via the created banner
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode}) http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
} }
// only pass the token to the template right after creation // #49: burn-after-N-reads budget (per-viewer, 15-minute dedupe window).
if justCreated { // Just-created first render does not count as a read for the creator.
a.renderPaste(w, row, true, token) if !justCreated {
rem, _ := a.store.registerRead(row, currentViewerID(r))
a.renderPaste(w, row, false, "", rem)
return return
} }
a.renderPaste(w, row, false, "") // only pass the token to the template right after creation
a.renderPaste(w, row, true, token, nil)
} }
var _ = strconv.Itoa var _ = strconv.Itoa
+333 -59
View File
@@ -3,14 +3,42 @@
--bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52; --bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52;
--muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8; --muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8;
--accent: #C4A8F0; --border: #42355C; --accent: #C4A8F0; --border: #42355C;
--radius: 14px; --radius-lg: 20px; --radius: 10px; --radius-sm: 999px;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace; --font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace;
} }
/* semantic status colors (dark preset values; light presets override below) */
:root {
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
--on-accent: #241B30; /* text placed on accent-colored backgrounds */
}
[data-preset="smooth"] { [data-preset="smooth"] {
--bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6; --bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6;
--muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36; --muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36;
--accent: #7A7796; --border: #DAD7E6; --accent: #7A7796; --border: #DAD7E6;
--ok: #456F45; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #F6F5FA;
}
[data-preset="pastel-lavender"] {
--bg: #e6e0f5; --surface: #f1edfa; --surface-2: #cbb8e7;
--muted: #a99cc9; --muted-fg: #5f5390; --fg: #3E3059;
--accent: #806bb8; --border: #c4b6e0;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #f1edfa;
}
[data-preset="pastel-peach"] {
--bg: #ffe0d6; --surface: #fff0ea; --surface-2: #ffc4a8;
--muted: #d9a08c; --muted-fg: #7a4632; --fg: #4F2318;
--accent: #f9826c; --border: #ffc9b5;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #4F2318;
}
[data-preset="pastel-cloud"] {
--bg: #fff0f6; --surface: #fff7fb; --surface-2: #ffc8dd;
--muted: #d9b9c9; --muted-fg: #7d5670; --fg: #4A3355;
--accent: #a2d2ff; --border: #ffccd9;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #274a6b;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -21,147 +49,175 @@ body {
line-height: 1.45; line-height: 1.45;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
min-height: 100vh; min-height: 100vh;
font-size: 14px; font-size: 24.2px;
} }
.topbar { .topbar {
display: flex; align-items: center; gap: 20px; display: flex; align-items: center; gap: 20px;
padding: 0 20px; height: 52px; padding: 0 22px; height: 76px;
background: var(--surface); border-bottom: 1px solid var(--border); background: var(--surface); border-bottom: 1px solid var(--border);
} }
.logo { font-weight: 700; font-size: 16px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); } .logo { font-weight: 700; font-size: 27.6px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); }
.logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; } .logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; }
.topbar nav { display: flex; gap: 4px; } .topbar nav { display: flex; gap: 4px; }
.topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: 8px; font-size: 13.5px; } .topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: var(--radius); font-size: 23.2px; }
.topbar nav a:hover { background: var(--surface-2); color: var(--fg); } .topbar nav a:hover { background: var(--surface-2); color: var(--fg); }
.topbar nav a.on { background: var(--accent); color: var(--bg); } .topbar nav a.on { background: var(--accent); color: var(--bg); }
.topbar .spacer { flex: 1; } .topbar .spacer { flex: 1; }
.kbd { font-family: var(--font-mono); font-size: 11px; border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; color: var(--muted-fg); } .kbd { font-family: var(--font-mono); font-size: 18.9px; border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 6px; color: var(--muted-fg); }
.float { .float {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
box-shadow: 0 2px 6px rgba(20,14,32,.25), 0 12px 32px rgba(20,14,32,.3); box-shadow:
0 1px 2px rgba(0, 0, 0, .10),
0 2px 6px rgba(0, 0, 0, .08),
0 8px 24px rgba(0, 0, 0, .07);
overflow: hidden; overflow: hidden;
} }
/* new paste page */ /* new paste page */
.deck { .deck {
display: grid; grid-template-columns: 1fr 300px; gap: 16px; display: grid; grid-template-columns: 1fr 300px; gap: 16px;
padding: 16px 20px; height: calc(100vh - 52px); padding: 16px 20px 20px; height: calc(100vh - 76px);
max-width: 1400px; margin: 0 auto; max-width: 1400px; margin: 0 auto;
} }
.pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; } .pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; }
.side-section { padding: 14px 16px; flex-shrink: 0; } .side-section { padding: 14px 16px; flex-shrink: 0; }
.side-section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; } .side-section h3 { font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
.pane-l { display: flex; flex-direction: column; } .pane-l-col { display: flex; flex-direction: column; gap: 14px; min-height: 0; }
.pane-l-head { flex-shrink: 0; }
.editor-head { .editor-head {
display: flex; align-items: center; gap: 12px; padding: 10px 16px; display: flex; align-items: center; gap: 12px; padding: 12px 16px;
border-bottom: 1px solid var(--border);
} }
.editor-head input { .editor-head input {
border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13.5px; flex: 1; border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 23.2px; flex: 1;
} }
.editor-head select { .editor-head select {
border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg);
border-radius: 7px; padding: 4px 10px; font: inherit; font-size: 12.5px; cursor: pointer; border-radius: var(--radius); padding: 4px 10px; font: inherit; font-size: 21.6px; cursor: pointer;
} }
/* shared code line metrics (#50): gutter + code must share one line box */
:root { --code-lh: 1.7; --code-fs: 21.6px; }
.editor-wrap { flex: 1; display: flex; min-height: 0; } .editor-wrap { flex: 1; display: flex; min-height: 0; }
.gutter { .gutter {
padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono); padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono);
font-size: 12.5px; line-height: 1.7; user-select: none; white-space: pre; overflow: hidden; font-size: var(--code-fs); line-height: var(--code-lh); user-select: none; white-space: pre; overflow: hidden;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
.editor { .editor {
flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7; flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 21.6px; line-height: 1.7;
white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg); white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg);
resize: none; width: 100%; resize: none; width: 100%;
} }
.editor::placeholder { color: var(--muted); } .editor::placeholder { color: var(--muted); }
.actionbar { .actionbar {
display: flex; align-items: center; gap: 14px; padding: 12px 16px; display: flex; align-items: center; gap: 14px;
border-top: 1px solid var(--border); padding: 10px 4px;
} }
.actionbar .btn { padding: 10px 26px; }
.btn { .btn {
background: var(--accent); color: var(--bg); border: none; cursor: pointer; background: var(--accent); color: var(--bg); border: none; cursor: pointer;
padding: 8px 18px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600; padding: 8px 18px; border-radius: var(--radius); font: inherit; font-size: 22.4px; font-weight: 600;
} }
.btn:hover { filter: brightness(1.08); } .btn:hover { filter: brightness(1.08); }
.hint { font-size: 12px; color: var(--muted-fg); } .hint { font-size: 20.7px; color: var(--muted-fg); }
.hint b { color: var(--fg); font-weight: 550; } .hint b { color: var(--fg); font-weight: 550; }
.seg { display: flex; flex-direction: column; gap: 2px; } .seg { display: flex; flex-direction: column; gap: 2px; }
.seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 7px; cursor: pointer; font-size: 13px; } .seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: var(--radius); cursor: pointer; font-size: 22.4px; }
.seg label:hover { background: var(--surface-2); } .seg label:hover { background: var(--surface-2); }
.seg input { accent-color: var(--accent); } .seg input { accent-color: var(--accent); }
.toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; padding: 5px 8px; border-radius: 7px; } .toggle { display: flex; align-items: center; gap: 8px; font-size: 22.4px; cursor: pointer; padding: 5px 8px; border-radius: var(--radius); }
.toggle:hover { background: var(--surface-2); } .toggle:hover { background: var(--surface-2); }
.toggle input { accent-color: var(--accent); } .toggle input { accent-color: var(--accent); }
.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; padding: 4px 0; } .deck .row { display: flex; justify-content: space-between; align-items: center; font-size: 22.4px; padding: 4px 0; }
.row input[type="text"] { .row input[type="text"] {
border: 1px solid var(--border); border-radius: 7px; padding: 5px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 12.5px; width: 130px; color: var(--fg); font: inherit; font-size: 21.6px; width: 130px;
} }
.created-banner { .created-banner {
display: none; padding: 10px 16px; font-size: 13px; background: var(--surface-2); display: none; padding: 10px 16px; font-size: 22.4px; background: var(--surface-2);
border-bottom: 1px solid var(--border); word-break: break-all; border-bottom: 1px solid var(--border); word-break: break-all;
} }
.created-banner a { color: var(--accent); } .created-banner a { color: var(--accent); }
/* paste view */ /* paste view */
.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; } .meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.meta-bar h1 { font-size: 17px; font-weight: 600; } .meta-bar h1 { font-size: 29.2px; font-weight: 600; }
.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: 7px; } .slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: var(--radius); }
.tag { font-size: 11.5px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; } .tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; }
.paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
.stats-pill { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.stats-head { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 14px 18px; cursor: pointer; text-align: left; }
.stats-head:hover { color: var(--fg); background: var(--surface-2); }
.stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; }
.stats-pill.open .stats-chev { transform: rotate(180deg); }
.stats-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: .01em; }
@media (max-width: 640px) { .stats-summary { white-space: normal; word-break: break-word; } }
.stats-body { border-top: 1px solid var(--border); }
.stats-grid { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; padding: 12px 16px; font-size: 20.7px; }
.stats-k { color: var(--muted-fg); }
.stats-v { color: var(--fg); word-break: break-all; }
.meta-bar .spacer { flex: 1; } .meta-bar .spacer { flex: 1; }
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 8px; padding: 5px 12px; font: inherit; font-size: 12.5px; cursor: pointer; text-decoration: none; } .iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 5px 12px; font: inherit; font-size: 21.6px; cursor: pointer; text-decoration: none; }
.iconbtn.gear { display: inline-flex; align-items: center; padding: 5px 9px; }
.iconbtn.gear svg { width: 22px; height: 22px; }
.settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); }
.settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
.settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; }
.iconbtn:hover { color: var(--fg); border-color: var(--muted); } .iconbtn:hover { color: var(--fg); border-color: var(--muted); }
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; } .iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
.code-head { .code-head {
display: flex; align-items: center; gap: 10px; padding: 8px 16px; display: flex; align-items: center; gap: 10px; padding: 8px 16px;
border-bottom: 1px solid var(--border); font-size: 12.5px; color: var(--muted-fg); border-bottom: 1px solid var(--border); font-size: 21.6px; color: var(--muted-fg);
} }
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } .code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.code { .code {
font-family: var(--font-mono); font-size: 13px; line-height: 1.7; font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh);
padding: 14px 0; display: flex; overflow-x: auto; padding: 14px 0; display: flex; overflow-x: auto;
} }
.code .gutter { flex-shrink: 0; } .code .gutter { flex-shrink: 0; }
/* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */
.code .gutter { padding-top: 0; padding-bottom: 0; }
.codebody { padding: 0 18px; white-space: pre; } .codebody { padding: 0 18px; white-space: pre; }
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 12px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; } /* syntax highlight tokens (#1) */
.tok-kw { color: #c792ea; }
.tok-str { color: #a5e075; }
.tok-num { color: #f78c6c; }
.tok-com { color: #6a737d; font-style: italic; }
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 20.7px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
/* history */ /* history */
.page { max-width: 860px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; } .page { max-width: 1200px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.head-row { display: flex; align-items: baseline; gap: 14px; } .head-row { display: flex; align-items: baseline; gap: 14px; }
.head-row h1 { font-size: 20px; font-weight: 600; } .head-row h1 { font-size: 34.5px; font-weight: 600; }
.search { .search {
display: flex; align-items: center; gap: 8px; background: var(--surface); display: flex; align-items: center; gap: 8px; background: var(--surface);
border: 1px solid var(--border); border-radius: 10px; padding: 8px 14px; width: 260px; border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 14px; width: 260px;
} }
.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13px; width: 100%; } .search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 22.4px; width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; } table { width: 100%; border-collapse: collapse; font-size: 22.4px; table-layout: fixed; }
th:nth-child(1), td:nth-child(1) { width: 130px; }
th:nth-child(2), td:nth-child(2) { width: auto; }
th:nth-child(3), td:nth-child(3) { width: 80px; }
th:nth-child(4), td:nth-child(4) { width: 80px; }
th:nth-child(5), td:nth-child(5) { width: 64px; }
th:nth-child(6), td:nth-child(6) { width: 90px; }
th { th {
text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; text-align: left; font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600; color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600;
} }
td { padding: 10px 16px; border-bottom: 1px solid var(--border); } td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); } tr.row:hover td { background: var(--surface-2); }
td a.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); text-decoration: none; } tr.row:hover td a.slug { color: var(--accent); }
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.slug:hover { color: var(--accent); } td a.slug:hover { color: var(--accent); }
.badge { font-size: 11px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: 999px; padding: 1px 8px; } .badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; }
.badge.lock { color: var(--accent); border-color: var(--accent); } .badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; } .dim { color: var(--muted-fg); white-space: nowrap; }
.pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 12.5px; color: var(--muted-fg); } .pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 21.6px; color: var(--muted-fg); }
.pager .pg { display: flex; gap: 6px; } .pager .pg { display: flex; gap: 6px; }
.pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 7px; padding: 4px 11px; font: inherit; font-size: 12.5px; cursor: pointer; } .pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 4px 11px; font: inherit; font-size: 21.6px; cursor: pointer; }
.pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); } .pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); }
.pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } .pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.pager button:disabled { opacity: .4; cursor: default; } .pager button:disabled { opacity: .4; cursor: default; }
.empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 13px; } .empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 22.4px; }
/* unlock */ /* unlock */
.center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); } .center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); }
@@ -169,16 +225,234 @@ td a.slug:hover { color: var(--accent); }
.inner { padding: 28px; text-align: center; } .inner { padding: 28px; text-align: center; }
.lockring { .lockring {
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%; width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 24px; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 41.4px;
} }
.inner h1 { font-size: 17px; font-weight: 600; margin-bottom: 6px; } .inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
.inner .sub { font-size: 13px; color: var(--muted-fg); margin-bottom: 20px; } .inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
.pwinput { .pwinput {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 9px; width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--fg); font: inherit; font-size: 13.5px; outline: none; text-align: center; background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
letter-spacing: .12em; letter-spacing: .12em;
} }
.pwinput:focus { border-color: var(--accent); } .pwinput:focus { border-color: var(--accent); }
.center .btn { width: 100%; margin-top: 12px; } .center .btn { width: 100%; margin-top: 12px; }
.err { display: none; margin-top: 12px; font-size: 12.5px; color: #ff8fa3; } .err { display: none; margin-top: 12px; font-size: 21.6px; color: #ff8fa3; }
.center .foot { font-size: 12px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); } .center .foot { font-size: 20.7px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
.btn-icon {
padding: 4px 8px; font-size: 24.2px; line-height: 1; overflow: visible;
display: inline-flex; align-items: center; justify-content: center;
min-width: 40px; height: 36px;
}
/* selection controls: pill-style selected states (#14) */
.seg label, .toggle {
border: 1px solid transparent;
transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.seg label:hover, .toggle:hover {
background: var(--surface-2);
color: var(--fg);
}
.seg label:has(input:checked),
.toggle:has(input:checked) {
background: var(--surface-2);
border-color: var(--accent);
color: var(--fg);
border-radius: var(--radius-sm);
}
.seg label:has(input:focus-visible),
.toggle:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.seg input, .toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; }
/* toast (#19) */
.toast {
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(8px);
background: var(--surface-2); color: var(--fg); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 6px 18px; font-size: 20.7px;
opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,.25);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* status variants (#16) */
.toast.success { border-color: var(--ok); color: var(--ok); }
.toast.error { border-color: var(--err); color: var(--err); }
/* protection section rhythm (#20) */
.protect { display: flex; flex-direction: column; gap: 2px; }
.protect .pw-row { padding: 2px 8px 4px; }
.pw-field {
display: flex; align-items: center; gap: 2px; width: 100%;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
}
.pw-field:focus-within { border-color: var(--accent); }
.pw-field input {
flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--fg);
font: inherit; font-size: 21.6px; padding: 7px 12px; letter-spacing: .08em;
}
.pw-field input::placeholder { color: var(--muted); letter-spacing: normal; }
.pw-field .reveal {
background: none; border: none; color: var(--muted-fg); cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0 10px; height: 100%;
flex-shrink: 0;
}
.pw-field .reveal:hover { color: var(--fg); }
.pw-field .reveal .eye-slash { display: none; }
.pw-field .reveal.off .eye-slash { display: block; }
.pw-field svg { width: 20px; height: 20px; display: block; }
/* custom URL input (#22) */
.deck .row input[type="text"] { width: 100%; }
.custom-input {
display: block; width: 100%;
border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 12px;
background: var(--bg); color: var(--fg); font: inherit; font-size: 21.6px; outline: none;
}
.custom-input:focus { border-color: var(--accent); }
.custom-input::placeholder { color: var(--muted); }
/* btn-icon svg (#24) */
.btn-icon svg { width: 20px; height: 20px; display: block; }
/* search spinner (#32) */
.search-spinner {
width: 16px; height: 16px; flex-shrink: 0;
border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%;
animation: spin .8s linear infinite; visibility: hidden;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* unlock redesign (#27) */
.unlock-card .pw-field { margin: 18px 0 4px; text-align: left; }
.unlock-card .pw-field input { text-align: left; }
.unlock-err { margin-top: 10px; font-size: 20.7px; color: #ff8fa3; }
/* paste name under slug pill in Paste column (#43) */
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paste-sub.dim { color: var(--muted); }
td .url-link { font-size: 19.8px; }
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); }
/* sortable column headers (#42) */
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
.sort-ind { display: inline-block; width: 0; height: 0; margin-left: 6px; vertical-align: middle; border-left: 5px solid transparent; border-right: 5px solid transparent; }
th.sorted.asc .sort-ind { border-bottom: 6px solid var(--accent); }
th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
/* ============================================================
Consistency audit (#18) — shared tokens across inputs, buttons,
headings. Visual-only, no behavior change.
============================================================ */
/* shared text-input treatment: .search input, .pw-field input, #title, #custom */
.search input,
.pw-field input,
.editor-head input#title,
.custom-input,
.row input[type="text"] {
font-size: 21.6px;
color: var(--fg);
}
.search input::placeholder,
.pw-field input::placeholder,
.editor-head input#title::placeholder,
.custom-input::placeholder {
color: var(--muted);
}
.custom-input { border-radius: var(--radius); }
/* buttons (incl. icon-only variants) share one radius + focus ring */
.btn-icon, .iconbtn {
border-radius: var(--radius);
transition: color .12s ease, border-color .12s ease, background .12s ease;
}
.iconbtn:focus-visible, .btn:focus-visible, .btn-icon:focus-visible, .pager button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* in-place copy success feedback (#53) */
.iconbtn.ok, .btn.ok {
color: var(--ok);
border-color: var(--ok);
}
/* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em;
}
/* consistent card padding scale: 12px 18px for wide card heads/bodies */
.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; }
/* topbar: tighten so logo + nav + gear fit */
.topbar { gap: 10px; padding: 0 12px; height: 56px; }
.logo { font-size: 17px; white-space: nowrap; }
.logo em { display: none; }
.topbar nav { gap: 2px; flex-shrink: 0; }
.topbar nav a { padding: 5px 8px; font-size: 15px; }
.iconbtn.gear { padding: 4px 7px; flex-shrink: 0; }
.iconbtn.gear svg { width: 18px; height: 18px; }
/* new paste: stack editor above sidebar, natural page height */
.deck {
display: flex; flex-direction: column;
height: auto; min-height: calc(100vh - 56px);
padding: 12px; gap: 12px;
}
.pane-l-col { order: 0; }
.pane-r { order: 1; overflow-y: visible; }
.editor-wrap { min-height: 45vh; }
.editor { font-size: 15px; }
.gutter { font-size: 15px; }
.editor-head input { min-width: 0; font-size: 16px; }
.editor-head select { max-width: 120px; font-size: 14px; }
.actionbar { flex-wrap: wrap; }
.actionbar .btn { padding: 12px 22px; }
.hint { font-size: 13px; }
/* history: horizontal-scroll table inside its card */
.page { padding: 12px; }
.head-row { flex-wrap: wrap; }
.head-row h1 { font-size: 22px; }
.search { width: 100%; }
.search input { font-size: 16px; }
.float { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { min-width: 720px; }
th { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
td { padding: 8px 10px; font-size: 14px; }
.pager { flex-wrap: wrap; gap: 8px; font-size: 13px; }
/* paste view */
.paste-title-bar { padding: 10px 12px; gap: 8px; }
.paste-title-bar h1 { font-size: 18px; }
.slug { font-size: 13px; word-break: break-all; }
.stats-head { font-size: 13px; }
.stats-grid { font-size: 13px; padding: 10px 12px; }
.code { font-size: 13px; }
.codebody { padding: 0 12px; }
.footnote { font-size: 12px; padding: 8px 12px; gap: 10px; }
.created-banner { font-size: 13px; }
.iconbtn { font-size: 13px; padding: 6px 10px; }
/* unlock card */
.center { height: auto; min-height: calc(100vh - 56px); padding: 16px; }
.center .float { width: 100%; }
.inner { padding: 20px 16px; }
.inner h1 { font-size: 20px; }
.inner .sub { font-size: 14px; }
.unlock-err { font-size: 13px; }
.center .foot { font-size: 13px; }
}
+162
View File
@@ -0,0 +1,162 @@
// Shared table logic for history (/api/public) and saved (/api/mine) pages (#57).
// Provides: live search, client-side sort with indicators, row rendering via
// a page-supplied rowHtml(), pagination state, and row click-through.
const PaletteTable = (() => {
const $ = id => document.getElementById(id);
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; };
const 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'; };
const 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';
};
const sortVal = (it, k) => {
let v = it[k];
if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase();
if (k === 'language') v = (v == null || v === '') ? 'text' : String(v).toLowerCase();
if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v;
return v == null ? null : v;
};
function init(opts) {
// opts: {endpoint, perPage, hasPager, rowHtml(it), emptyFiltered, emptyAll}
const state = { filter: '', sortKey: null, sortDir: 1, page: 1, total: 0 };
let timer = null;
function sortItems(items) {
if (!state.sortKey) return items;
const k = state.sortKey, dir = state.sortDir;
return items.slice().sort((a, b) => {
const va = sortVal(a, k), vb = sortVal(b, k);
const na = va == null, nb = vb == null;
if (na && nb) return 0;
if (na) return 1;
if (nb) return -1;
if (va < vb) return -1 * dir;
if (va > vb) return 1 * dir;
return (a.created_at || 0) < (b.created_at || 0) ? 1 : -1;
});
}
function matches(it) {
if (!state.filter) return true;
const f = state.filter.toLowerCase();
return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f);
}
function renderSortIndicators() {
document.querySelectorAll('th.sortable').forEach(th => {
th.classList.toggle('sorted', th.dataset.sort === state.sortKey);
th.classList.toggle('asc', th.dataset.sort === state.sortKey && state.sortDir === 1);
th.classList.toggle('desc', th.dataset.sort === state.sortKey && state.sortDir === -1);
});
}
async function load() {
const spinner = $('search-spinner');
if (spinner) spinner.style.visibility = 'visible';
try {
const filtered = state.filter.length > 0;
const off = (state.page - 1) * opts.perPage;
const url = (filtered || state.sortKey)
? opts.endpoint + '?limit=500&offset=0'
: opts.endpoint + '?limit=' + opts.perPage + '&offset=' + off;
const res = await fetch(url);
const data = await res.json();
state.total = data.total;
let items = filtered ? data.items.filter(matches) : data.items;
items = sortItems(items);
const count = $('count');
if (count) count.textContent = filtered
? items.length.toLocaleString() + ' matches (of ' + state.total.toLocaleString() + ' total)'
: state.total.toLocaleString() + ' total';
const rows = $('rows'), empty = $('empty');
if (!items.length) {
rows.innerHTML = '';
empty.style.display = 'block';
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
} else {
empty.style.display = 'none';
rows.innerHTML = items.map(opts.rowHtml).join('');
}
const pager = $('pg'), showing = $('showing');
if (opts.hasPager && pager && showing) {
const pages = Math.max(1, Math.ceil(state.total / opts.perPage));
if (filtered || state.sortKey) {
showing.textContent = state.sortKey
? 'Sorted by ' + state.sortKey + ' (' + (state.sortDir === 1 ? 'ascending' : 'descending') + ') · ' + items.length.toLocaleString() + ' of ' + state.total.toLocaleString()
: 'Showing ' + items.length.toLocaleString() + ' matches for "' + state.filter + '"';
pager.innerHTML = '';
} else {
showing.textContent = state.total === 0 ? 'Nothing here yet' :
`Showing ${off+1}${Math.min(off+opts.perPage, state.total)} of ${state.total.toLocaleString()} · page ${state.page} of ${pages}`;
const btns = [];
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', state.page-1, {dis: state.page===1});
const win = new Set([1, 2, state.page-1, state.page, state.page+1, pages]);
let last = 0;
for (let i = 1; i <= pages; i++) {
if (win.has(i)) {
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
add(String(i), i, {on: i===state.page});
last = i;
}
}
add('', state.page+1, {dis: state.page===pages});
pager.innerHTML = btns.join('');
}
} else if (showing) {
showing.textContent = '';
}
renderSortIndicators();
} finally {
if (spinner) spinner.style.visibility = 'hidden';
}
}
document.querySelector('thead').addEventListener('click', e => {
const th = e.target.closest('th.sortable');
if (!th) return;
const k = th.dataset.sort;
if (state.sortKey === k) { state.sortDir = -state.sortDir; } else { state.sortKey = k; state.sortDir = 1; }
renderSortIndicators();
load();
});
const rows = $('rows');
if (rows) rows.addEventListener('click', e => {
const tr = e.target.closest('tr.row[data-href]');
if (!tr || e.target.closest('a') || e.target.closest('button')) return;
window.location.href = tr.dataset.href;
});
const pg = $('pg');
if (pg) pg.addEventListener('click', e => {
const b = e.target.closest('button[data-p]');
if (!b || b.disabled) return;
state.page = parseInt(b.dataset.p);
load();
window.scrollTo(0, 0);
});
const filter = $('filter');
if (filter) filter.addEventListener('input', e => {
clearTimeout(timer);
timer = setTimeout(() => {
state.filter = e.target.value.trim();
state.page = 1;
load();
}, 200);
});
return { load, state, esc, fmtSize, ago };
}
return { init, esc, fmtSize, ago };
})();
+21 -1
View File
@@ -1 +1,21 @@
{{define "foot"}}{{end}} {{define "foot"}}<script>
// live relative-time counters (#46): tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, 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 tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
</script>{{end}}
+34 -71
View File
@@ -4,86 +4,49 @@
<div class="head-row"> <div class="head-row">
<h1>Public pastes</h1> <h1>Public pastes</h1>
<span class="count" id="count"></span> <span class="count" id="count"></span>
<div class="spacer"></div>
<div class="search"><input id="filter" placeholder="filter…"></div>
</div> </div>
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <table>
<thead><tr><th>Paste</th><th>Description</th><th>Language</th><th>Size</th><th>Views</th><th>Created</th></tr></thead> <colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:96px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="view_count" class="sortable"><span class="sort-ind"></span>Views</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
</tr></thead>
<tbody id="rows"></tbody> <tbody id="rows"></tbody>
</table> </table>
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div> <div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
<div class="pager"> </div>
<span id="showing"></span> <div class="pager float">
<div class="pg" id="pg"></div> <span id="showing"></span>
</div> <div class="pg" id="pg"></div>
</div> </div>
</div> </div>
<script src="/static/table.js"></script>
<script> <script>
const PER = 25; const t = PaletteTable.init({
let page = 1, total = 0; endpoint: '/api/public',
const $ = id => document.getElementById(id); perPage: 25,
hasPager: true,
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; } rowHtml: it =>
function fmtSize(n) { if (n == null) return '—'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; } `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
function ago(ts) { (it.title
const s = Math.floor(Date.now()/1000) - ts; ? `${t.esc(it.title)}`
if (s < 60) return s + 's ago'; : `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
if (s < 3600) return Math.floor(s/60) + 'm ago'; `</td>` +
if (s < 86400) return Math.floor(s/3600) + 'h ago'; `<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
return Math.floor(s/86400) + 'd ago'; `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
} (it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
async function load() { emptyFiltered: 'No pastes match your search.',
const off = (page - 1) * PER; emptyAll: 'No pastes yet. Create the first one.',
const res = await fetch('/api/public?limit=' + PER + '&offset=' + off);
const data = await res.json();
total = data.total;
$('count').textContent = total.toLocaleString() + ' total';
const rows = $('rows');
if (data.items.length === 0) {
rows.innerHTML = '';
$('empty').style.display = 'block';
} else {
$('empty').style.display = 'none';
rows.innerHTML = data.items.map(it =>
`<tr class="row"><td><a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a></td>` +
`<td class="title-cell">${it.title ? esc(it.title) : '<span class=dim>—</span>'}</td>` +
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
`<td class="dim">${fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim">${ago(it.created_at)}</td></tr>`
).join('');
}
const pages = Math.max(1, Math.ceil(total / PER));
$('showing').textContent = total === 0 ? 'Nothing here yet' :
`Showing ${off+1}${Math.min(off+PER, total)} of ${total.toLocaleString()} · page ${page} of ${pages}`;
const btns = [];
const add = (label, target, opts={}) => btns.push(`<button ${opts.on?'class="on"':''} ${opts.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', page-1, {dis: page===1});
const win = new Set([1, 2, page-1, page, page+1, pages]);
let last = 0;
for (let i = 1; i <= pages; i++) {
if (win.has(i)) {
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
add(String(i), i, {on: i===page});
last = i;
}
}
add('', page+1, {dis: page===pages});
$('pg').innerHTML = btns.join('');
}
$('pg').addEventListener('click', e => {
const b = e.target.closest('button[data-p]');
if (!b || b.disabled) return;
page = parseInt(b.dataset.p);
load();
window.scrollTo(0, 0);
}); });
$('filter').addEventListener('input', () => { page = 1; load(); }); t.load();
load(); setInterval(t.load, 30000); // auto-refresh history every 30s
setInterval(load, 30000); // auto-refresh history every 30s
</script> </script>
{{template "foot" .}} {{template "foot" .}}
+14 -3
View File
@@ -2,16 +2,27 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<script>
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
(function () {
var t = new URLSearchParams(location.search).get('theme');
if (t) document.documentElement.dataset.preset = t;
})();
</script>
{{end}} {{end}}
{{define "topbar"}} {{define "topbar"}}
<div class="topbar"> <div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</em></a> <a class="logo" href="/history">Palette <em>/ beta</em></a>
<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}}>history</a> <a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
<a href="https://git.archfox.org/poslop/palette">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">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</a>
</div> </div>
{{end}} {{end}}
+73
View File
@@ -0,0 +1,73 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Saved pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:150px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>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 src="/static/table.js"></script>
<script>
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);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 50,
hasPager: false,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
</script>
{{template "foot" .}}
+188 -26
View File
@@ -1,26 +1,32 @@
{{template "head" .}} {{template "head" .}}
{{template "topbar" .}} {{template "topbar" .}}
<div class="deck"> <div class="deck">
<div class="float pane-l"> <div class="pane-l-col">
<div class="editor-head"> <div class="float pane-l-head">
<input id="title" placeholder="title (optional)"> <div class="editor-head">
<select id="language"> <input id="title" placeholder="Title">
<option value="">auto</option> <select id="language">
<option>go</option><option>python</option><option>javascript</option> <option value="">auto</option>
<option>rust</option><option>c</option><option>cpp</option><option>java</option> <option>go</option><option>python</option><option>javascript</option><option>typescript</option>
<option>bash</option><option>sql</option><option>yaml</option><option>json</option> <option>rust</option><option>c</option><option>cpp</option><option>java</option><option>csharp</option>
<option>markdown</option><option>text</option> <option>bash</option><option>sql</option><option>yaml</option><option>json</option>
</select> <option>html</option><option>css</option><option>xml</option><option>php</option>
<option>ruby</option><option>perl</option><option>lua</option><option>dockerfile</option>
<option>toml</option><option>ini</option><option>diff</option>
<option>markdown</option><option>text</option>
</select>
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>
</div>
</div> </div>
<div class="editor-wrap"> <div class="float editor-wrap">
<div class="gutter" id="gutter">1</div> <div class="gutter" id="gutter">1</div>
<textarea class="editor" id="content" placeholder="paste your code, text, or notes here…" spellcheck="false"></textarea> <textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
</div> </div>
<div class="created-banner" id="created"></div> <div class="created-banner" id="created"></div>
<div class="actionbar"> <div class="actionbar">
<span class="hint">Ctrl+Enter to create</span> <span class="hint">Ctrl+Enter to create</span>
<div class="spacer" style="flex:1"></div> <div class="spacer" style="flex:1"></div>
<button class="btn" id="create">Create</button> <button class="btn" id="create">Create</button>
</div> </div>
</div> </div>
@@ -33,22 +39,38 @@
<label><input type="radio" name="exp" value="24h"> 1 day</label> <label><input type="radio" name="exp" value="24h"> 1 day</label>
<label><input type="radio" name="exp" value="168h" checked> 1 week</label> <label><input type="radio" name="exp" value="168h" checked> 1 week</label>
<label><input type="radio" name="exp" value="720h"> 30 days</label> <label><input type="radio" name="exp" value="720h"> 30 days</label>
<label><input type="radio" name="exp" value="custom"> Custom</label>
</div>
<div class="pw-row" id="customexp-row" style="display:none">
<input type="number" id="expnum" min="1" style="width:80px" placeholder="90">
<select id="expunit">
<option value="m">minutes</option>
<option value="h" selected>hours</option>
<option value="d">days</option>
<option value="w">weeks</option>
<option value="mo">months</option>
</select>
<div class="hint" id="customexp-err" style="display:none; color:var(--danger, #c0392b); margin-top:6px;"></div>
</div> </div>
</div> </div>
<div class="float side-section"> <div class="float side-section">
<h3>Protection</h3> <h3>Protection</h3>
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label> <div class="protect">
<input type="password" id="password" class="pwinput" placeholder="password" style="display:none; margin: 6px 8px 0; width: auto;"> <label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label> <div class="pw-row" id="pwrow" style="display:none"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label> <label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<div class="pw-row" id="burnrow" style="display:none"><label class="hint" style="font-size:19px;">Readable <input type="number" id="burnreads" min="1" value="1" style="width:64px"> times</label></div>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
</div> </div>
<div class="float side-section"> <div class="float side-section">
<h3>Custom URL</h3> <h3>Custom URL</h3>
<div class="row"><span>/</span><input type="text" id="custom" placeholder="my-snippet"></div> <input type="text" id="custom" class="custom-input" placeholder="/my-snippet">
<div class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
</div> </div>
<div class="float side-section"> <div class="float side-section" id="result-card" style="display:none">
<h3>Result</h3> <h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all"></div> <div class="hint" id="result" style="word-break:break-all">empty</div>
</div> </div>
</div> </div>
</div> </div>
@@ -65,7 +87,113 @@ function updateGutter() {
content.addEventListener('input', updateGutter); content.addEventListener('input', updateGutter);
updateGutter(); updateGutter();
$('haspw').addEventListener('change', e => { $('password').style.display = e.target.checked ? 'block' : 'none'; }); 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);
}
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
$('burn').addEventListener('change', e => { $('burnrow').style.display = e.target.checked ? 'block' : 'none'; });
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
$('customexp-row').style.display = document.querySelector('input[name="exp"]:checked').value === 'custom' ? 'block' : 'none';
$('customexp-err').style.display = 'none';
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.style.display = 'block';
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.style.display = 'block';
return null;
}
err.style.display = 'none';
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
function showResult(html, isError) {
$('result').innerHTML = html;
$('result').dataset.token = isError ? '' : ($('result').dataset.token || '');
$('result-card').style.display = 'block';
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() { async function create() {
const body = { const body = {
@@ -75,9 +203,16 @@ async function create() {
custom_slug: $('custom').value || null, custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked, burn_after_read: $('burn').checked,
}; };
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value; if ($('haspw').checked) body.password = $('password').value;
const exp = document.querySelector('input[name="exp"]:checked').value; const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp) body.expires_in = exp; if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', { const res = await fetch('/api/pastes', {
method: 'POST', method: 'POST',
@@ -86,17 +221,44 @@ async function create() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
$('result').textContent = 'Error: ' + (data.error || res.status); showResult('Error: ' + (data.error || res.status), true);
toast('Create failed', 'error');
return; return;
} }
const url = location.origin + '/' + (data.custom_slug || data.id); const url = location.origin + '/' + (data.custom_slug || data.id);
$('result').innerHTML = '<a href="' + url + '">' + url + '</a>'; showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
$('result').dataset.token = data.deletion_token || ''; $('result').dataset.token = data.deletion_token || '';
try { navigator.clipboard.writeText(url); } catch(e) {} const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
const dest = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
// password-protected: unlock now with the password we already have (#26)
if ($('haspw').checked && data.id) {
const fd = new FormData();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
await fetch('/' + data.id, {method: 'POST', body: fd});
} catch(e) {}
}
// show the paste // show the paste
location.href = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || ''); location.href = dest;
} }
$('create').addEventListener('click', create); $('create').addEventListener('click', create);
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.style.display = 'none';
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); } if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
}); });
+51 -12
View File
@@ -2,17 +2,36 @@
{{template "topbar" .}} {{template "topbar" .}}
<div class="page"> <div class="page">
<div class="float"> <div class="float">
<div class="meta-bar"> <div class="paste-title-bar">
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1> <h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
<span class="slug">/{{.ID}}</span> {{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
{{if .Language}}<span class="tag">{{.Language}}</span>{{end}}
{{if .ExpiresAt}}<span class="tag">expires in {{.ExpiresIn}}</span>{{end}}
<div class="spacer"></div> <div class="spacer"></div>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a> <a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" onclick="copyContent(); return false;">copy</a> <a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}} {{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
</div> </div>
</div> </div>
<div class="float">
<div class="stats-pill" id="stats-pill">
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false" onclick="toggleStats()">
<svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
<span class="stats-summary">{{.StatsSummary}}</span>
</button>
<div class="stats-body" id="stats-body" hidden>
<div class="stats-grid">
<span class="stats-k">Language</span><span class="stats-v">{{if .Language}}{{.Language}}{{else}}text{{end}}</span>
<span class="stats-k">Size</span><span class="stats-v">{{.SizeHuman}} ({{.LineCount}} lines)</span>
<span class="stats-k">Views</span><span class="stats-v">{{.ViewCount}}</span>
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
{{if .ExpiresAt}}<span class="stats-k">Expires</span><span class="stats-v">in {{.ExpiresIn}}</span>{{end}}
<span class="stats-k">Password</span><span class="stats-v">{{if .HasPassword}}protected{{else}}none{{end}}</span>
{{if .BurnAfterRead}}{{if .ReadsLimit}}{{with .ReadsLeftN}}<span class="stats-k">Reads left</span><span class="stats-v">{{.}} of {{$.ReadsTotal}}</span>{{end}}{{else}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}{{end}}
{{if .CustomSlug}}<span class="stats-k">Custom URL</span><span class="stats-v">/{{.CustomSlug}}</span>{{end}}
<span class="stats-k">Visibility</span><span class="stats-v">{{.Visibility}}</span>
</div>
</div>
</div>
</div>
{{if .JustCreated}} {{if .JustCreated}}
<div class="float"> <div class="float">
<div class="created-banner" style="display:block"> <div class="created-banner" style="display:block">
@@ -22,19 +41,39 @@
</div> </div>
{{end}} {{end}}
<div class="float"> <div class="float">
<div class="code-head"><span class="dot"></span> {{.LineCount}} lines · {{.SizeBytes}} bytes</div>
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div> <div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
<div class="footnote">
<span>Created {{.CreatedAgo}}</span>
<span>{{.ViewCount}} views</span>
<span>{{.Visibility}}</span>
</div>
</div> </div>
</div> </div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}"> <input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script> <script>
function copyContent() { function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value); navigator.clipboard.writeText(document.getElementById('raw-content').value);
// in-place success feedback (#53)
if (btn) {
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
}
} }
function redeem(token) { function redeem(token) {
if (!confirm('Hard delete this paste immediately?')) return; if (!confirm('Hard delete this paste immediately?')) return;
+13
View File
@@ -0,0 +1,13 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Settings</h1>
</div>
<div class="settings-body">
<p>Settings are under construction.</p>
</div>
</div>
</div>
{{template "foot" .}}
+17 -6
View File
@@ -1,18 +1,29 @@
{{template "head" .}} {{template "head" .}}
{{template "topbar" .}} {{template "topbar" .}}
<div class="center"> <div class="center">
<div class="float"> <div class="float unlock-card">
<div class="inner"> <div class="inner">
<div class="lockring">🔒</div> <div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<h1>This paste is locked</h1> <h1>This paste is locked</h1>
<p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p> <p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p>
<form method="post" action="/unlock/{{.ID}}"> <form method="post" action="">
<input type="password" name="password" class="pwinput" placeholder="••••••••" autofocus> <div class="pw-field">
{{if .Wrong}}<p class="err" style="display:block">Wrong password. Try again.</p>{{end}} <input type="password" name="password" id="password" placeholder="Password" autocomplete="current-password" autofocus>
<button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
{{if .Wrong}}<p class="unlock-err">Wrong password. Try again.</p>{{end}}
<button class="btn" type="submit">Unlock</button> <button class="btn" type="submit">Unlock</button>
</form> </form>
</div> </div>
<div class="foot">Created {{.CreatedAgo}}</div> <div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div>
</div> </div>
</div> </div>
<script>
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
</script>
{{template "foot" .}} {{template "foot" .}}