115 lines
3.6 KiB
Go
115 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// genDeletionToken returns a 32-char url-safe random token
|
|
func genDeletionToken() string {
|
|
b := make([]byte, 24)
|
|
rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// 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
|
|
|
|
// burnViewerWindowMinutes returns the admin-tunable per-viewer dedupe window
|
|
// (#40), falling back to the 15-minute default from #49.
|
|
func burnViewerWindowMinutes() int {
|
|
if globalSettingsFn != nil {
|
|
if m := globalSettings().BurnViewerWindowMinutes; m > 0 {
|
|
return m
|
|
}
|
|
}
|
|
return readWindowMinutes
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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 < int64(burnViewerWindowMinutes())*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 {
|
|
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
|
|
}
|
|
|
|
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
|
|
// DELETE /api/pastes/{id}/redeem?token=...
|
|
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
token := r.URL.Query().Get("token")
|
|
if token == "" {
|
|
writeErr(w, 400, "token required")
|
|
return
|
|
}
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil || row == nil {
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
if row.DeletionToken.String == "" || !deletionTokenEqual(row.DeletionToken.String, token) {
|
|
writeErr(w, 403, "invalid token")
|
|
return
|
|
}
|
|
// hard delete: pastes table row goes away entirely
|
|
a.store.db.Exec(`DELETE FROM pastes WHERE id = ?`, row.ID)
|
|
writeJSON(w, 200, map[string]string{"status": "deleted"})
|
|
}
|