Compare commits
2
Commits
2e1ce508fa
...
d5a47b1a31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5a47b1a31 | ||
|
|
127c12c79a |
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -16,14 +18,61 @@ func genDeletionToken() string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// maybeBurn marks a paste soft-deleted if burn_after_read is set.
|
||||
// Returns true if this read consumed the paste.
|
||||
func (s *Store) maybeBurn(row *PasteRow) bool {
|
||||
if !row.BurnAfterRead {
|
||||
return false
|
||||
// readWindowMinutes is the per-viewer dedupe window for burn-after-N-reads
|
||||
// (#49): the same viewer cookie returning within 15 minutes does not count
|
||||
// as a new read. See the decision comment on issue #49.
|
||||
const readWindowMinutes = 15
|
||||
|
||||
// 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)
|
||||
return true
|
||||
now := timeNow().Unix()
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,14 @@ type Paste struct {
|
||||
Password *string `json:"password,omitempty"`
|
||||
ExpiresIn *string `json:"expires_in,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"`
|
||||
CanID *string `json:"can_id,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||
ViewerID string `json:"-"` // set from vwr cookie server-side (#37)
|
||||
readsLimit *int64 // #49: resolved read budget, not serialized
|
||||
ViewCount int `json:"view_count"`
|
||||
DeletionToken string `json:"-"`
|
||||
}
|
||||
@@ -64,6 +66,8 @@ type PasteRow struct {
|
||||
PasswordHash sql.NullString
|
||||
ExpiresAt sql.NullInt64
|
||||
BurnAfterRead bool
|
||||
ReadsLimit sql.NullInt64
|
||||
ReadsUsed int
|
||||
Visibility string
|
||||
CanID sql.NullString
|
||||
CreatedAt int64
|
||||
@@ -135,6 +139,14 @@ func (s *Store) migrate() error {
|
||||
`)
|
||||
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
|
||||
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -192,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
|
||||
if visibility == "" {
|
||||
visibility = "public"
|
||||
@@ -211,9 +232,9 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
||||
}
|
||||
p.DeletionToken = genDeletionToken()
|
||||
_, err := s.db.Exec(`INSERT INTO pastes
|
||||
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID)
|
||||
(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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -225,10 +246,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
||||
}
|
||||
|
||||
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
|
||||
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id
|
||||
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)
|
||||
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, &r.ViewerID)
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -521,6 +542,10 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 404, "paste expired")
|
||||
return
|
||||
}
|
||||
if row.burned() { // #49: read budget exhausted
|
||||
writeErr(w, 404, "paste not found")
|
||||
return
|
||||
}
|
||||
if row.PasswordHash.Valid {
|
||||
// require password via header or query
|
||||
pw := r.Header.Get("X-Paste-Password")
|
||||
@@ -538,11 +563,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
a.store.maybeBurn(row)
|
||||
rem, _ := a.store.registerRead(row, currentViewerID(r)) // #49 (also covers legacy burn)
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
||||
"language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt,
|
||||
"view_count": row.ViewCount, "visibility": row.Visibility,
|
||||
"reads_remaining": rem,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -635,6 +661,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "password required", 401)
|
||||
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)
|
||||
a.store.IncrementViews(row.ID)
|
||||
w.Write([]byte(row.Content))
|
||||
|
||||
@@ -100,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
|
||||
gutter := ""
|
||||
for i := 1; i <= lines; i++ {
|
||||
@@ -137,6 +137,9 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
|
||||
"ExpiresAt": row.ExpiresAt.Valid,
|
||||
"ExpiresIn": expIn,
|
||||
"DeletionToken": deletionToken,
|
||||
"ReadsLimit": row.ReadsLimit.Valid,
|
||||
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
|
||||
"ReadsTotal": int(row.ReadsLimit.Int64),
|
||||
"JustCreated": justCreated,
|
||||
"Host": "this host",
|
||||
}
|
||||
@@ -181,7 +184,7 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
a.renderPaste(w, row, false, "")
|
||||
a.renderPaste(w, row, false, "", nil)
|
||||
return
|
||||
}
|
||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||
@@ -195,19 +198,21 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
a.store.IncrementViews(row.ID)
|
||||
justCreated := r.URL.Query().Get("created") == "1"
|
||||
token := r.URL.Query().Get("token")
|
||||
if justCreated && token != "" {
|
||||
// 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})
|
||||
}
|
||||
// only pass the token to the template right after creation
|
||||
if justCreated {
|
||||
a.renderPaste(w, row, true, token)
|
||||
// #49: burn-after-N-reads budget (per-viewer, 15-minute dedupe window).
|
||||
// Just-created first render does not count as a read for the creator.
|
||||
if !justCreated {
|
||||
rem, _ := a.store.registerRead(row, currentViewerID(r))
|
||||
a.renderPaste(w, row, false, "", rem)
|
||||
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
|
||||
|
||||
+58
-1
@@ -39,6 +39,18 @@
|
||||
<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="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 class="float side-section">
|
||||
@@ -47,6 +59,7 @@
|
||||
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</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="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>
|
||||
@@ -86,6 +99,43 @@ function toast(msg, kind) {
|
||||
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';
|
||||
@@ -153,9 +203,16 @@ async function create() {
|
||||
custom_slug: $('custom').value || null,
|
||||
burn_after_read: $('burn').checked,
|
||||
};
|
||||
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
|
||||
if ($('haspw').checked) body.password = $('password').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', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<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}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}
|
||||
{{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>
|
||||
|
||||
Reference in New Issue
Block a user