Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
This commit is contained in:
1 parent
a3349b4a98
commit
4f1e901f04
43 files changed
+1251
-1203
No files matched your search
@@ -2,3 +2,5 @@ palette
|
|||||||
palette.db
|
palette.db
|
||||||
palette.db-shm
|
palette.db-shm
|
||||||
palette.db-wal
|
palette.db-wal
|
||||||
|
admin-key
|
||||||
|
settings.json
|
||||||
+1
-1
@@ -9,7 +9,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette .
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette ./cmd/palette
|
||||||
|
|
||||||
# ---- runtime stage ----
|
# ---- runtime stage ----
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
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"})
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -37,8 +38,8 @@ func defaultSettings(cfg Config) Settings {
|
|||||||
RateLimitPerMinute: 60, // 1 req/sec refill
|
RateLimitPerMinute: 60, // 1 req/sec refill
|
||||||
MaxContentBytes: cfg.MaxTextBytes,
|
MaxContentBytes: cfg.MaxTextBytes,
|
||||||
DefaultExpiry: "", // no default: pastes are permanent unless expires_in given
|
DefaultExpiry: "", // no default: pastes are permanent unless expires_in given
|
||||||
CustomSlugReservationDays: customSlugReservationDays,
|
CustomSlugReservationDays: 30,
|
||||||
BurnViewerWindowMinutes: readWindowMinutes,
|
BurnViewerWindowMinutes: 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,8 @@ type settingsStore struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSettingsStore(dbPath string, cfg Config) *settingsStore {
|
// LoadSettingsStore loads (or initializes) the settings store.
|
||||||
|
func LoadSettingsStore(dbPath string, cfg Config) *settingsStore {
|
||||||
p := filepath.Join(filepath.Dir(dbPath), "settings.json")
|
p := filepath.Join(filepath.Dir(dbPath), "settings.json")
|
||||||
ss := &settingsStore{cur: defaultSettings(cfg), path: p}
|
ss := &settingsStore{cur: defaultSettings(cfg), path: p}
|
||||||
if b, err := os.ReadFile(p); err == nil {
|
if b, err := os.ReadFile(p); err == nil {
|
||||||
@@ -95,7 +97,7 @@ func (ss *settingsStore) set(s Settings) error {
|
|||||||
}
|
}
|
||||||
if s.DefaultExpiry != "" {
|
if s.DefaultExpiry != "" {
|
||||||
d, err := time.ParseDuration(s.DefaultExpiry)
|
d, err := time.ParseDuration(s.DefaultExpiry)
|
||||||
if err != nil || !validExpiry(d) {
|
if err != nil || !store.ValidExpiry(d) {
|
||||||
return fmt.Errorf("default_expiry must be a duration between 1 minute and 1 year (or empty)")
|
return fmt.Errorf("default_expiry must be a duration between 1 minute and 1 year (or empty)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,8 @@ func (ss *settingsStore) set(s Settings) error {
|
|||||||
|
|
||||||
// resetAdminKeyFile deletes the persisted admin key file (if any) and returns
|
// resetAdminKeyFile deletes the persisted admin key file (if any) and returns
|
||||||
// the path so callers can regenerate. Used by --reset-admin-key (#40).
|
// the path so callers can regenerate. Used by --reset-admin-key (#40).
|
||||||
func resetAdminKeyFile(dbPath string) string {
|
// ResetAdminKeyFile deletes the persisted admin key file (if any).
|
||||||
|
func ResetAdminKeyFile(dbPath string) string {
|
||||||
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
|
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
|
||||||
os.Remove(p)
|
os.Remove(p)
|
||||||
return p
|
return p
|
||||||
@@ -120,7 +123,10 @@ func resetAdminKeyFile(dbPath string) string {
|
|||||||
// resolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
|
// resolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
|
||||||
// persisted key file is reused; else a new 32-char hex key is generated and
|
// persisted key file is reused; else a new 32-char hex key is generated and
|
||||||
// persisted with 0600 perms.
|
// persisted with 0600 perms.
|
||||||
func resolveAdminKey(dbPath string) (string, error) {
|
// ResolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
|
||||||
|
// persisted key file is reused; else a new 32-char hex key is generated and
|
||||||
|
// persisted with 0600 perms.
|
||||||
|
func ResolveAdminKey(dbPath string) (string, error) {
|
||||||
if v := os.Getenv("PALETTE_ADMIN_KEY"); v != "" {
|
if v := os.Getenv("PALETTE_ADMIN_KEY"); v != "" {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
@@ -142,9 +148,11 @@ func resolveAdminKey(dbPath string) (string, error) {
|
|||||||
|
|
||||||
// handleResetAdminKey implements the --reset-admin-key flag: delete the key
|
// handleResetAdminKey implements the --reset-admin-key flag: delete the key
|
||||||
// file, generate a fresh key, print it.
|
// file, generate a fresh key, print it.
|
||||||
func handleResetAdminKey(dbPath string) {
|
// HandleResetAdminKey implements the --reset-admin-key flag: delete the key
|
||||||
p := resetAdminKeyFile(dbPath)
|
// file, generate a fresh key, print it.
|
||||||
key, err := resolveAdminKey(dbPath)
|
func HandleResetAdminKey(dbPath string) {
|
||||||
|
p := ResetAdminKeyFile(dbPath)
|
||||||
|
key, err := ResolveAdminKey(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("reset admin key: %v", err)
|
log.Fatalf("reset admin key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -174,10 +182,6 @@ func (a *apiServer) adminAuth(next http.HandlerFunc, key string) http.HandlerFun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *apiServer) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
renderPage(w, "admin.html", map[string]any{"Page": "admin"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
func (a *apiServer) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, 200, a.settings.get())
|
writeJSON(w, 200, a.settings.get())
|
||||||
}
|
}
|
||||||
@@ -194,3 +198,6 @@ func (a *apiServer) handleAdminPostSettings(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
writeJSON(w, 200, a.settings.get())
|
writeJSON(w, 200, a.settings.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get returns the current settings (exported for cmd wiring).
|
||||||
|
func (ss *settingsStore) Get() Settings { return ss.get() }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// newTestSettingsStore builds an in-memory settings store with a temp file.
|
// newTestSettingsStore builds an in-memory settings store with a temp file.
|
||||||
func newTestSettingsStore(t *testing.T, cfg Config) *settingsStore {
|
func NewTestSettingsStore(t *testing.T, cfg Config) *settingsStore {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ss := loadSettingsStore(filepath.Join(dir, "palette.db"), cfg)
|
ss := LoadSettingsStore(filepath.Join(dir, "palette.db"), cfg)
|
||||||
// point persistence at a temp path (dir(dbPath) == dir)
|
// point persistence at a temp path (dir(dbPath) == dir)
|
||||||
return ss
|
return ss
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestAdminEnvKeyPrecedence(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
dbPath := filepath.Join(dir, "palette.db")
|
dbPath := filepath.Join(dir, "palette.db")
|
||||||
t.Setenv("PALETTE_ADMIN_KEY", "envkey1234567890abcdef")
|
t.Setenv("PALETTE_ADMIN_KEY", "envkey1234567890abcdef")
|
||||||
key, err := resolveAdminKey(dbPath)
|
key, err := ResolveAdminKey(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func TestAdminEnvKeyPrecedence(t *testing.T) {
|
|||||||
|
|
||||||
// unset env: file takes over
|
// unset env: file takes over
|
||||||
os.Unsetenv("PALETTE_ADMIN_KEY")
|
os.Unsetenv("PALETTE_ADMIN_KEY")
|
||||||
key2, err := resolveAdminKey(dbPath)
|
key2, err := ResolveAdminKey(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestAdminEnvKeyPrecedence(t *testing.T) {
|
|||||||
t.Fatalf("admin-key file perms: %v err %v", fi, err)
|
t.Fatalf("admin-key file perms: %v err %v", fi, err)
|
||||||
}
|
}
|
||||||
// reuse on subsequent boots
|
// reuse on subsequent boots
|
||||||
key3, _ := resolveAdminKey(dbPath)
|
key3, _ := ResolveAdminKey(dbPath)
|
||||||
if key3 != key2 {
|
if key3 != key2 {
|
||||||
t.Fatal("persisted key not reused")
|
t.Fatal("persisted key not reused")
|
||||||
}
|
}
|
||||||
@@ -158,10 +158,10 @@ func TestAdminResetKey(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
dbPath := filepath.Join(dir, "palette.db")
|
dbPath := filepath.Join(dir, "palette.db")
|
||||||
os.Unsetenv("PALETTE_ADMIN_KEY")
|
os.Unsetenv("PALETTE_ADMIN_KEY")
|
||||||
key1, _ := resolveAdminKey(dbPath)
|
key1, _ := ResolveAdminKey(dbPath)
|
||||||
// direct invocation of the reset behavior
|
// direct invocation of the reset behavior
|
||||||
resetAdminKeyFile(dbPath)
|
ResetAdminKeyFile(dbPath)
|
||||||
key2, _ := resolveAdminKey(dbPath)
|
key2, _ := ResolveAdminKey(dbPath)
|
||||||
if key1 == key2 {
|
if key1 == key2 {
|
||||||
t.Fatal("reset did not regenerate key")
|
t.Fatal("reset did not regenerate key")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// burnViewerWindow returns the admin-tunable per-viewer dedupe window
|
||||||
|
// (#40), falling back to the 15-minute default from #49.
|
||||||
|
func (a *apiServer) burnViewerWindow() int {
|
||||||
|
if a.settings != nil {
|
||||||
|
if m := a.settings.get().BurnViewerWindowMinutes; m > 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" || !store.DeletionTokenEqual(row.DeletionToken.String, token) {
|
||||||
|
writeErr(w, 403, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// hard delete: pastes table row goes away entirely
|
||||||
|
a.store.HardDelete(row.ID)
|
||||||
|
writeJSON(w, 200, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -71,7 +71,7 @@ func TestDeletionTokenRedeem(t *testing.T) {
|
|||||||
|
|
||||||
// gone for good: even soft-deleted lookup returns nothing, and row count is 0
|
// gone for good: even soft-deleted lookup returns nothing, and row count is 0
|
||||||
var n int
|
var n int
|
||||||
s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&n)
|
n = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
|
||||||
if n != 0 {
|
if n != 0 {
|
||||||
t.Fatal("row still exists after redeem")
|
t.Fatal("row still exists after redeem")
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -87,19 +89,19 @@ func TestBurnReadsWindowExpiryRecounts(t *testing.T) {
|
|||||||
id := createBurnReads(t, h, 2)
|
id := createBurnReads(t, h, 2)
|
||||||
|
|
||||||
base := time.Now()
|
base := time.Now()
|
||||||
timeNow = func() time.Time { return base }
|
store.TimeNow = func() time.Time { return base }
|
||||||
t.Cleanup(func() { timeNow = time.Now })
|
t.Cleanup(func() { store.TimeNow = time.Now })
|
||||||
|
|
||||||
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
t.Fatalf("read 1: %d", rec.Code)
|
t.Fatalf("read 1: %d", rec.Code)
|
||||||
}
|
}
|
||||||
// 10 minutes later: still within window, deduped
|
// 10 minutes later: still within window, deduped
|
||||||
timeNow = func() time.Time { return base.Add(10 * time.Minute) }
|
store.TimeNow = func() time.Time { return base.Add(10 * time.Minute) }
|
||||||
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
t.Fatalf("re-read within window: %d", rec.Code)
|
t.Fatalf("re-read within window: %d", rec.Code)
|
||||||
}
|
}
|
||||||
// 20 minutes after first read: window expired, counts as read 2
|
// 20 minutes after first read: window expired, counts as read 2
|
||||||
timeNow = func() time.Time { return base.Add(20 * time.Minute) }
|
store.TimeNow = func() time.Time { return base.Add(20 * time.Minute) }
|
||||||
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
t.Fatalf("re-read after window expected 200, got %d", rec.Code)
|
t.Fatalf("re-read after window expected 200, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -130,13 +132,6 @@ func TestBurnReadsDefaultOne(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBurnReadsPageViewCounts(t *testing.T) {
|
func TestBurnReadsPageViewCounts(t *testing.T) {
|
||||||
if webUIInstance == nil {
|
|
||||||
ui, err := NewWebUI()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
webUIInstance = ui
|
|
||||||
}
|
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
h := s.routes()
|
h := s.routes()
|
||||||
id := createBurnReads(t, h, 2)
|
id := createBurnReads(t, h, 2)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"palette/internal/store"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -45,7 +45,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var pwHash *string
|
var pwHash *string
|
||||||
if password != "" {
|
if password != "" {
|
||||||
h, err := hashPassword(password)
|
h, err := store.Argon2IDHash(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, "hash error")
|
writeErr(w, 500, "hash error")
|
||||||
return
|
return
|
||||||
@@ -53,9 +53,8 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
pwHash = &h
|
pwHash = &h
|
||||||
}
|
}
|
||||||
|
|
||||||
canID := genSlug(8)
|
canID := store.GenSlug(8)
|
||||||
_, err := a.store.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
|
err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
|
||||||
VALUES (?,?,?,?,?,?,?)`, canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, "db error")
|
writeErr(w, 500, "db error")
|
||||||
return
|
return
|
||||||
@@ -76,7 +75,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
lang := it["language"]
|
lang := it["language"]
|
||||||
if err := a.store.insertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
|
if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
|
||||||
writeErr(w, 500, "db error")
|
writeErr(w, 500, "db error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -104,7 +103,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
contentStr := string(content)
|
contentStr := string(content)
|
||||||
if err := a.store.insertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
|
if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
|
||||||
writeErr(w, 500, "db error")
|
writeErr(w, 500, "db error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,7 +113,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if itemCount == 0 {
|
if itemCount == 0 {
|
||||||
a.store.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
|
a.store.DeleteCan(canID)
|
||||||
writeErr(w, 400, "can needs at least one item (files or json_items)")
|
writeErr(w, 400, "can needs at least one item (files or json_items)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,45 +143,7 @@ func detectContentType(name string, content []byte) string {
|
|||||||
return "text/plain"
|
return "text/plain"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) insertCanItem(canID, title, content, contentType string, language, expiresAt *string, binary *string, now int64) error {
|
|
||||||
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
|
|
||||||
_, err := s.db.Exec(`INSERT INTO pastes
|
|
||||||
(id, content, content_type, language, title, visibility, can_id, created_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?)`,
|
|
||||||
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
|
|
||||||
_ = expiresAt
|
|
||||||
_ = binary
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetCan(id string) (*CanRow, error) {
|
|
||||||
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at
|
|
||||||
FROM paste_cans WHERE id = ? AND deleted_at IS NULL`, id)
|
|
||||||
var c CanRow
|
|
||||||
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &c, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
|
|
||||||
rows, err := s.db.Query(`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
|
|
||||||
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []PasteRow
|
|
||||||
for rows.Next() {
|
|
||||||
var r PasteRow
|
|
||||||
if err := rows.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); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
@@ -204,7 +165,7 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
if pw == "" {
|
if pw == "" {
|
||||||
pw = r.URL.Query().Get("password")
|
pw = r.URL.Query().Get("password")
|
||||||
}
|
}
|
||||||
if pw == "" || !checkPassword(can.PasswordHash.String, pw) {
|
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
|
||||||
writeErr(w, 401, "password required")
|
writeErr(w, 401, "password required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -224,12 +185,12 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
metas := make([]itemMeta, 0, len(items))
|
metas := make([]itemMeta, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
metas = append(metas, itemMeta{
|
metas = append(metas, itemMeta{
|
||||||
ID: it.ID, Title: nullStrPtr(it.Title), ContentType: it.ContentType,
|
ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
|
||||||
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
|
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{
|
writeJSON(w, 200, map[string]any{
|
||||||
"id": can.ID, "title": nullStrPtr(can.Title), "visibility": can.Visibility,
|
"id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
|
||||||
"created_at": can.CreatedAt, "items": metas,
|
"created_at": can.CreatedAt, "items": metas,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -253,7 +214,7 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
if pw == "" {
|
if pw == "" {
|
||||||
pw = r.URL.Query().Get("password")
|
pw = r.URL.Query().Get("password")
|
||||||
}
|
}
|
||||||
if pw == "" || !checkPassword(can.PasswordHash.String, pw) {
|
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
|
||||||
writeErr(w, 401, "password required")
|
writeErr(w, 401, "password required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -61,7 +63,7 @@ func TestCustomSlugValidation(t *testing.T) {
|
|||||||
func TestSlugCollisionWithAutoID(t *testing.T) {
|
func TestSlugCollisionWithAutoID(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
// manually insert a paste, then try to claim its auto ID as a custom slug
|
// manually insert a paste, then try to claim its auto ID as a custom slug
|
||||||
p, err := s.store.CreatePaste(&Paste{Content: "auto"})
|
p, err := s.store.CreatePaste(&store.Paste{Content: "auto"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"palette/internal/lang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGuessLang serves POST /api/guess-language.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
l := lang.GuessLang(req.Content)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"language": l})
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -12,22 +15,19 @@ import (
|
|||||||
func testServer(t *testing.T) *apiServer {
|
func testServer(t *testing.T) *apiServer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
globalLimiter = newLimiter() // fresh buckets per test
|
globalLimiter = newLimiter() // fresh buckets per test
|
||||||
if webUIInstance == nil {
|
ui, err := web.New()
|
||||||
ui, err := NewWebUI()
|
if err != nil {
|
||||||
if err != nil {
|
t.Fatal(err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
webUIInstance = ui
|
|
||||||
}
|
}
|
||||||
store, err := OpenStore(":memory:")
|
st, err := store.OpenStore(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}
|
cfg := Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}
|
||||||
ss := newTestSettingsStore(t, cfg)
|
ss := NewTestSettingsStore(t, cfg)
|
||||||
globalSettingsFn = ss.get
|
globalSettingsFn = ss.get
|
||||||
t.Cleanup(func() { globalSettingsFn = nil })
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
return &apiServer{store: store, cfg: cfg, settings: ss, adminKey: "test-admin-key"}
|
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAndGetPaste(t *testing.T) {
|
func TestCreateAndGetPaste(t *testing.T) {
|
||||||
@@ -188,13 +188,13 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
|||||||
s.store.SoftDelete(created.ID)
|
s.store.SoftDelete(created.ID)
|
||||||
|
|
||||||
// simulate grace elapsed
|
// simulate grace elapsed
|
||||||
past := time.Now().Unix() - (softDeleteGraceDays+1)*86400
|
past := time.Now().Unix() - (store.SoftDeleteGraceDays+1)*86400
|
||||||
s.store.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
|
s.store.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
|
||||||
|
|
||||||
s.store.SweepExpired()
|
s.store.SweepExpired()
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&count)
|
count = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
t.Fatal("expected hard delete after grace period")
|
t.Fatal("expected hard delete after grace period")
|
||||||
}
|
}
|
||||||
@@ -202,9 +202,9 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
|||||||
|
|
||||||
func TestSlugCharset(t *testing.T) {
|
func TestSlugCharset(t *testing.T) {
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
s := genSlug(6)
|
s := store.GenSlug(6)
|
||||||
for _, c := range s {
|
for _, c := range s {
|
||||||
if !strings.ContainsRune(slugAlphabet, c) {
|
if !strings.ContainsRune(store.SlugAlphabet, c) {
|
||||||
t.Fatalf("bad char %q in slug %q", c, s)
|
t.Fatalf("bad char %q in slug %q", c, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -40,20 +43,19 @@ func viewerCookieFor(t *testing.T, h http.Handler, path string) string {
|
|||||||
|
|
||||||
func TestMineCreateListDelete(t *testing.T) {
|
func TestMineCreateListDelete(t *testing.T) {
|
||||||
globalLimiter = newLimiter() // fresh rate-limit buckets
|
globalLimiter = newLimiter() // fresh rate-limit buckets
|
||||||
webUI, err := NewWebUI()
|
st, err := store.OpenStore(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
webUIInstance = webUI
|
ui, err := web.New()
|
||||||
store, err := OpenStore(":memory:")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
|
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
|
||||||
ss := newTestSettingsStore(t, cfg)
|
ss := NewTestSettingsStore(t, cfg)
|
||||||
globalSettingsFn = ss.get
|
globalSettingsFn = ss.get
|
||||||
t.Cleanup(func() { globalSettingsFn = nil })
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
a := &apiServer{store: store, cfg: cfg, settings: ss, adminKey: "test-admin-key"}
|
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
h := a.routes()
|
h := a.routes()
|
||||||
|
|
||||||
alice := viewerCookieFor(t, h, "/history")
|
alice := viewerCookieFor(t, h, "/history")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -70,8 +70,7 @@ func globalSettings() Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rateLimitCreate uses the admin-tunable burst and per-minute refill (#40).
|
// rateLimitCreate uses the admin-tunable burst and per-minute refill (#40).
|
||||||
func rateLimitCreate(r *http.Request) bool {
|
func rateLimitCreate(r *http.Request, s Settings) bool {
|
||||||
s := globalSettings()
|
|
||||||
return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst)
|
return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/lang"
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,22 +16,19 @@ import (
|
|||||||
func newTestServer(t *testing.T) *apiServer {
|
func newTestServer(t *testing.T) *apiServer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
globalLimiter = newLimiter() // fresh buckets per test
|
globalLimiter = newLimiter() // fresh buckets per test
|
||||||
store, err := OpenStore(t.TempDir() + "/test.db")
|
st, err := store.OpenStore(t.TempDir() + "/test.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if webUIInstance == nil {
|
ui, err := web.New()
|
||||||
ui, err := NewWebUI()
|
if err != nil {
|
||||||
if err != nil {
|
t.Fatal(err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
webUIInstance = ui
|
|
||||||
}
|
}
|
||||||
cfg := Config{MaxTextBytes: 1024 * 1024}
|
cfg := Config{MaxTextBytes: 1024 * 1024}
|
||||||
ss := newTestSettingsStore(t, cfg)
|
ss := NewTestSettingsStore(t, cfg)
|
||||||
globalSettingsFn = ss.get
|
globalSettingsFn = ss.get
|
||||||
t.Cleanup(func() { globalSettingsFn = nil })
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
return &apiServer{store: store, cfg: cfg, settings: ss, adminKey: "test-admin-key"}
|
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
|
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
|
||||||
@@ -150,7 +151,7 @@ func TestRateLimitUnlock(t *testing.T) {
|
|||||||
// TestHighlightCode basic expectations.
|
// TestHighlightCode basic expectations.
|
||||||
func TestHighlightCode(t *testing.T) {
|
func TestHighlightCode(t *testing.T) {
|
||||||
in := "func main() {\n\t// comment\n\tfmt.Println(\"hello\")\n}\n"
|
in := "func main() {\n\t// comment\n\tfmt.Println(\"hello\")\n}\n"
|
||||||
out := highlightCode(in, "go")
|
out := lang.HighlightCode(in, "go")
|
||||||
if !bytes.Contains([]byte(out), []byte(`<span class="tok-kw">func</span>`)) {
|
if !bytes.Contains([]byte(out), []byte(`<span class="tok-kw">func</span>`)) {
|
||||||
t.Fatalf("no keyword span: %s", out)
|
t.Fatalf("no keyword span: %s", out)
|
||||||
}
|
}
|
||||||
@@ -161,12 +162,12 @@ func TestHighlightCode(t *testing.T) {
|
|||||||
t.Fatalf("no string span: %s", out)
|
t.Fatalf("no string span: %s", out)
|
||||||
}
|
}
|
||||||
// unsupported language returns escaped plain text
|
// unsupported language returns escaped plain text
|
||||||
plain := highlightCode("<b>x</b>", "text")
|
plain := lang.HighlightCode("<b>x</b>", "text")
|
||||||
if plain != "<b>x</b>" {
|
if plain != "<b>x</b>" {
|
||||||
t.Fatalf("plain escaping wrong: %q", plain)
|
t.Fatalf("plain escaping wrong: %q", plain)
|
||||||
}
|
}
|
||||||
// line count preserved (gutter alignment)
|
// line count preserved (gutter alignment)
|
||||||
if got := len(splitLines(highlightCode("a\nb\nc", "go"))); got != 3 {
|
if got := len(splitLines(lang.HighlightCode("a\nb\nc", "go"))); got != 3 {
|
||||||
t.Fatalf("want 3 lines, got %d", got)
|
t.Fatalf("want 3 lines, got %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
// Package api implements palette's REST handlers and the HTTP router:
|
||||||
|
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DBPath string
|
||||||
|
MaxTextBytes int64
|
||||||
|
MaxItemBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiServer struct {
|
||||||
|
store *store.Store
|
||||||
|
cfg Config
|
||||||
|
ui *web.UI
|
||||||
|
settings *settingsStore
|
||||||
|
adminKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
|
||||||
|
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns the HTTP handler for the server.
|
||||||
|
func (a *apiServer) Routes() http.Handler {
|
||||||
|
return a.routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) routes() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
|
r.Use(viewerCookieMiddleware)
|
||||||
|
|
||||||
|
// admin (#40): HTML page is open (key entry via form); API is key-guarded
|
||||||
|
r.Get("/admin", a.ui.Handlers().HandleAdminPage)
|
||||||
|
r.Get("/admin/api/settings", a.adminAuth(a.handleAdminGetSettings, a.adminKey))
|
||||||
|
r.Post("/admin/api/settings", a.adminAuth(a.handleAdminPostSettings, a.adminKey))
|
||||||
|
|
||||||
|
// API
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Post("/pastes", a.handleCreatePaste)
|
||||||
|
r.Get("/pastes/{id}", a.handleGetPaste)
|
||||||
|
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
||||||
|
r.Get("/mine", a.handleListMine)
|
||||||
|
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
||||||
|
r.Get("/public", a.handleListPublic)
|
||||||
|
r.Post("/guess-language", a.handleGuessLang)
|
||||||
|
r.Post("/pastes/can", a.handleCreateCan)
|
||||||
|
r.Get("/cans/{id}", a.handleGetCan)
|
||||||
|
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
||||||
|
})
|
||||||
|
|
||||||
|
// can page
|
||||||
|
r.Get("/can/{id}", a.handleCanPage)
|
||||||
|
|
||||||
|
// raw
|
||||||
|
r.Get("/raw/{id}", a.handleRaw)
|
||||||
|
|
||||||
|
// web pages
|
||||||
|
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
|
||||||
|
r.Get("/new", a.ui.Handlers().HandleNewPage)
|
||||||
|
r.Get("/history", a.ui.Handlers().HandleHistoryPage)
|
||||||
|
r.Get("/settings", a.ui.Handlers().HandleSettingsPage)
|
||||||
|
r.Get("/mine", a.ui.Handlers().HandleMinePage)
|
||||||
|
r.Handle("/static/*", a.ui.StaticHandler())
|
||||||
|
r.Get("/unlock/{id}", a.handlePasteView)
|
||||||
|
r.Post("/unlock/{id}", a.handlePasteView)
|
||||||
|
r.Get("/{id}", a.handlePasteView)
|
||||||
|
r.Post("/{id}", a.handlePasteView)
|
||||||
|
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeErr(w, 404, "not found")
|
||||||
|
})
|
||||||
|
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 := store.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) {
|
||||||
|
s := a.settings.get()
|
||||||
|
setRateLimitHeaders(w, 1, 5)
|
||||||
|
if !rateLimitCreate(r, s) {
|
||||||
|
writeRateLimited(w, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p store.Paste
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
|
writeErr(w, 400, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Content) == "" {
|
||||||
|
writeErr(w, 400, "content is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int64(len(p.Content)) > s.MaxContentBytes { // #40: admin-tunable
|
||||||
|
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", s.MaxContentBytes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #40: admin-configurable default expiry
|
||||||
|
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
|
||||||
|
def := s.DefaultExpiry
|
||||||
|
p.ExpiresIn = &def
|
||||||
|
}
|
||||||
|
p.ViewerID = currentViewerID(r)
|
||||||
|
created, err := a.store.CreatePaste(&p)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 201, map[string]any{
|
||||||
|
"id": created.ID,
|
||||||
|
"deletion_token": created.DeletionToken,
|
||||||
|
"url": "/" + created.ID,
|
||||||
|
"raw_url": "/raw/" + created.ID,
|
||||||
|
"api_url": "/api/pastes/" + created.ID,
|
||||||
|
"expires_at": created.ExpiresAt,
|
||||||
|
"created_at": created.CreatedAt,
|
||||||
|
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
row, err := a.store.GetPaste(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
|
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")
|
||||||
|
if pw == "" {
|
||||||
|
pw = r.URL.Query().Get("password")
|
||||||
|
}
|
||||||
|
if pw == "" || !store.CheckPassword(row.PasswordHash.String, pw) {
|
||||||
|
writeErr(w, 401, "password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rem, _ := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow()) // #49 (also covers legacy burn)
|
||||||
|
writeJSON(w, 200, map[string]any{
|
||||||
|
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
||||||
|
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
|
||||||
|
"view_count": row.ViewCount, "visibility": row.Visibility,
|
||||||
|
"reads_remaining": rem,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
row, err := a.store.GetPaste(id)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// viewer-cookie delete enforcement (#37): only the browser that created
|
||||||
|
// the paste (matching vwr) may delete it via this endpoint. Requests with
|
||||||
|
// no client-sent vwr cookie (plain API clients) are unaffected.
|
||||||
|
vid := currentViewerID(r)
|
||||||
|
if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid {
|
||||||
|
writeErr(w, 403, "not your paste")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.SoftDelete(row.ID); err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 := store.NullStrPtr(row.Language), store.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": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 25
|
||||||
|
}
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
rows, total, err := a.store.ListPublic(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 := store.NullStrPtr(row.Language), store.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": store.NullStrPtr(row.CustomSlug),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
row, err := a.store.GetPaste(id)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
http.Error(w, "not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
|
http.Error(w, "paste expired", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.PasswordHash.Valid {
|
||||||
|
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 dedupe window as page views.
|
||||||
|
a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||||
|
// #34: content_type is attacker-controlled via the create API. Serving it
|
||||||
|
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
||||||
|
// render as active content on this origin when fetched from /raw —
|
||||||
|
// stored XSS. Only pass through a fixed safe set; anything else is
|
||||||
|
// served as plain text with nosniff.
|
||||||
|
ct := row.ContentType
|
||||||
|
if !safeRawContentType(ct) {
|
||||||
|
ct = "text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
a.store.IncrementViews(row.ID)
|
||||||
|
w.Write([]byte(row.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeRawContentType reports whether ct is in the fixed set of types that are
|
||||||
|
// safe to serve verbatim on /raw (no active-content execution contexts).
|
||||||
|
func safeRawContentType(ct string) bool {
|
||||||
|
base := ct
|
||||||
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||||
|
base = ct[:i]
|
||||||
|
}
|
||||||
|
base = strings.ToLower(strings.TrimSpace(base))
|
||||||
|
switch base {
|
||||||
|
case "text/plain", "text/markdown", "text/x-markdown",
|
||||||
|
"application/json", "application/pdf",
|
||||||
|
"image/png", "image/jpeg", "image/gif", "image/webp",
|
||||||
|
"application/octet-stream":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
can, err := a.store.GetCan(id)
|
||||||
|
if err != nil || can == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, _ := a.store.ListCanItems(can.ID)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
|
||||||
|
for _, it := range items {
|
||||||
|
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "</ul></body></html>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateEsc(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := a.webHandlers()
|
||||||
|
// unlock POST rate limiting is wired through h.RateLimitOK
|
||||||
|
h.HandlePasteView(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) webHandlers() *web.Handlers {
|
||||||
|
return &web.Handlers{
|
||||||
|
UI: a.ui,
|
||||||
|
Store: a.store,
|
||||||
|
ViewerID: currentViewerID,
|
||||||
|
BurnWindowMin: a.burnViewerWindow,
|
||||||
|
RateLimitOK: func(id string, r *http.Request) bool { return rateLimitUnlock(id, r) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func envIntOr(k string, d int) int {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullStrOr(ns sql.NullString, def string) string {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.String
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvOr returns the env var value or default.
|
||||||
|
func EnvOr(k, d string) string { return envOr(k, d) }
|
||||||
|
|
||||||
|
// EnvIntOr returns the env int value or default.
|
||||||
|
func EnvIntOr(k string, d int) int { return envIntOr(k, d) }
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
|
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
|
||||||
// created_at/expires_at, bypassing the API's timestamp handling.
|
// created_at/expires_at, bypassing the API's timestamp handling.
|
||||||
func insertPasteWithSlug(t *testing.T, s *Store, slug string, createdAt, expiresAt int64) string {
|
func insertPasteWithSlug(t *testing.T, s *store.Store, slug string, createdAt, expiresAt int64) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
id := genSlug(6)
|
id := store.GenSlug(6)
|
||||||
_, err := s.db.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
|
if _, err := s.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt)
|
VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
@@ -24,14 +25,14 @@ func TestReleaseSlugOnExpiredPaste(t *testing.T) {
|
|||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
|
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
|
||||||
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
|
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
|
||||||
t.Fatalf("released %d err %v, want 1", n, err)
|
t.Fatalf("released %d err %v, want 1", n, err)
|
||||||
}
|
}
|
||||||
if taken, _ := s.store.SlugTaken("release-notes"); taken {
|
if taken, _ := s.store.SlugTaken("release-notes"); taken {
|
||||||
t.Fatal("slug should be released after expiry")
|
t.Fatal("slug should be released after expiry")
|
||||||
}
|
}
|
||||||
// slug must be reusable by a new paste
|
// slug must be reusable by a new paste
|
||||||
p, err := s.store.CreatePaste(&Paste{Content: "new", CustomSlug: strPtr("release-notes")})
|
p, err := s.store.CreatePaste(&store.Paste{Content: "new", CustomSlug: strPtr("release-notes")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reuse slug: %v", err)
|
t.Fatalf("reuse slug: %v", err)
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,7 @@ func TestReleaseSlugOnOldPaste(t *testing.T) {
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
// created 31 days ago, no expiry -> released by 30-day reservation rule
|
// created 31 days ago, no expiry -> released by 30-day reservation rule
|
||||||
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
|
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
|
||||||
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
|
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
|
||||||
t.Fatalf("released %d err %v, want 1", n, err)
|
t.Fatalf("released %d err %v, want 1", n, err)
|
||||||
}
|
}
|
||||||
if taken, _ := s.store.SlugTaken("old-url"); taken {
|
if taken, _ := s.store.SlugTaken("old-url"); taken {
|
||||||
@@ -58,7 +59,7 @@ func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) {
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
|
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
|
||||||
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
|
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
|
||||||
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 0 {
|
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 0 {
|
||||||
t.Fatalf("released %d err %v, want 0", n, err)
|
t.Fatalf("released %d err %v, want 0", n, err)
|
||||||
}
|
}
|
||||||
for _, slug := range []string{"fresh-url", "fresh-url2"} {
|
for _, slug := range []string{"fresh-url", "fresh-url2"} {
|
||||||
@@ -72,7 +73,7 @@ func TestSweeperTickerReleasesSlugs(t *testing.T) {
|
|||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
|
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
|
||||||
s.store.StartSweeper(10 * time.Millisecond)
|
s.store.StartSweeper(10*time.Millisecond, store.SlugReservationDays)
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
|
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -46,13 +46,6 @@ func TestCreatePasteExpiryBounds(t *testing.T) {
|
|||||||
// missing from handlePasteView, so the counter only moved on /raw.
|
// missing from handlePasteView, so the counter only moved on /raw.
|
||||||
func TestPasteViewIncrementsViewCount(t *testing.T) {
|
func TestPasteViewIncrementsViewCount(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
if webUIInstance == nil {
|
|
||||||
ui, err := NewWebUI()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
webUIInstance = ui
|
|
||||||
}
|
|
||||||
h := s.routes()
|
h := s.routes()
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vc"}`))
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vc"}`))
|
||||||
@@ -87,13 +80,6 @@ func TestPasteViewIncrementsViewCount(t *testing.T) {
|
|||||||
// view for the creator.
|
// view for the creator.
|
||||||
func TestJustCreatedViewDoesNotCount(t *testing.T) {
|
func TestJustCreatedViewDoesNotCount(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
if webUIInstance == nil {
|
|
||||||
ui, err := NewWebUI()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
webUIInstance = ui
|
|
||||||
}
|
|
||||||
h := s.routes()
|
h := s.routes()
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"jc"}`))
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"jc"}`))
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package main
|
package lang
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -58,7 +57,8 @@ var canonical = map[string]string{
|
|||||||
// paths (empty, JSON, unambiguous markers enry can't see without a filename),
|
// paths (empty, JSON, unambiguous markers enry can't see without a filename),
|
||||||
// then enry strategies (shebangs, XML decl, modelines, content heuristics),
|
// then enry strategies (shebangs, XML decl, modelines, content heuristics),
|
||||||
// then enry's classifier seeded by our regex hints.
|
// then enry's classifier seeded by our regex hints.
|
||||||
func guessLang(s string) string {
|
// GuessLang detects a language from pasted content.
|
||||||
|
func GuessLang(s string) string {
|
||||||
src := strings.TrimSpace(s)
|
src := strings.TrimSpace(s)
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -108,19 +108,3 @@ func normalizeLang(lang string) string {
|
|||||||
return strings.ToLower(lang)
|
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})
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package lang
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,8 +23,8 @@ func TestGuessLangExisting(t *testing.T) {
|
|||||||
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
|
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
|
||||||
}
|
}
|
||||||
for src, want := range cases {
|
for src, want := range cases {
|
||||||
if got := guessLang(src); got != want {
|
if got := GuessLang(src); got != want {
|
||||||
t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
|
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,8 +47,8 @@ func TestGuessLangNewLanguages(t *testing.T) {
|
|||||||
"diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1 +1 @@\n": "diff",
|
"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 {
|
for src, want := range cases {
|
||||||
if got := guessLang(src); got != want {
|
if got := GuessLang(src); got != want {
|
||||||
t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
|
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +70,8 @@ func TestGuessLangMagicMarkers(t *testing.T) {
|
|||||||
"--- a/config.yml\n+++ b/config.yml\n@@ -1,2 +1,3 @@\n": "diff",
|
"--- a/config.yml\n+++ b/config.yml\n@@ -1,2 +1,3 @@\n": "diff",
|
||||||
}
|
}
|
||||||
for src, want := range cases {
|
for src, want := range cases {
|
||||||
if got := guessLang(src); got != want {
|
if got := GuessLang(src); got != want {
|
||||||
t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
|
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,14 +79,14 @@ func TestGuessLangMagicMarkers(t *testing.T) {
|
|||||||
// TestGuessLangCanonical verifies enry display names are mapped/lowercased to
|
// TestGuessLangCanonical verifies enry display names are mapped/lowercased to
|
||||||
// our stored ids.
|
// our stored ids.
|
||||||
func TestGuessLangCanonical(t *testing.T) {
|
func TestGuessLangCanonical(t *testing.T) {
|
||||||
if got := guessLang("FROM debian:12\nCMD [\"sh\"]\n"); got != "dockerfile" {
|
if got := GuessLang("FROM debian:12\nCMD [\"sh\"]\n"); got != "dockerfile" {
|
||||||
t.Errorf("Dockerfile canonical mapping failed: got %q", got)
|
t.Errorf("Dockerfile canonical mapping failed: got %q", got)
|
||||||
}
|
}
|
||||||
if got := guessLang("#!/bin/sh\necho hi\n"); got != "bash" {
|
if got := GuessLang("#!/bin/sh\necho hi\n"); got != "bash" {
|
||||||
t.Errorf("Shell canonical mapping failed: got %q", got)
|
t.Errorf("Shell canonical mapping failed: got %q", got)
|
||||||
}
|
}
|
||||||
// uncurated languages still come back lowercase
|
// uncurated languages still come back lowercase
|
||||||
if got := guessLang("<h1>{{.Name}}</h1>\n"); got != strings.ToLower(got) {
|
if got := GuessLang("<h1>{{.Name}}</h1>\n"); got != strings.ToLower(got) {
|
||||||
t.Errorf("expected lowercase output, got %q", got)
|
t.Errorf("expected lowercase output, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package lang
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html"
|
"html"
|
||||||
@@ -135,8 +135,8 @@ func containsStr(list []string, s string) bool {
|
|||||||
|
|
||||||
// highlightCode returns HTML with highlighting spans; safe because all
|
// highlightCode returns HTML with highlighting spans; safe because all
|
||||||
// non-token text is html-escaped.
|
// non-token text is html-escaped.
|
||||||
func highlightCode(content, lang string) string {
|
func HighlightCode(content, langID string) string {
|
||||||
l, h, ok := resolveLang(lang)
|
l, h, ok := resolveLang(langID)
|
||||||
_ = l
|
_ = l
|
||||||
if !ok {
|
if !ok {
|
||||||
return html.EscapeString(content)
|
return html.EscapeString(content)
|
||||||
@@ -144,7 +144,7 @@ func highlightCode(content, lang string) string {
|
|||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
out := make([]string, len(lines))
|
out := make([]string, len(lines))
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
out[i] = highlightLine(line, h, lang)
|
out[i] = highlightLine(line, h, langID)
|
||||||
}
|
}
|
||||||
return strings.Join(out, "\n")
|
return strings.Join(out, "\n")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genDeletionToken returns a 32-char url-safe random token
|
||||||
|
func genDeletionToken() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
cryptoRead(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 the burn viewer window 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, burnWindowMinutes int) (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(burnWindowMinutes)*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
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -16,16 +15,16 @@ var reservedSlugs = map[string]bool{
|
|||||||
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var errInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
|
var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
|
||||||
var errReservedSlug = errors.New("that slug is reserved")
|
var ErrReservedSlug = errors.New("that slug is reserved")
|
||||||
var errSlugTaken = errors.New("that slug is already taken")
|
var ErrSlugTaken = errors.New("that slug is already taken")
|
||||||
|
|
||||||
func ValidateCustomSlug(slug string) error {
|
func ValidateCustomSlug(slug string) error {
|
||||||
if !slugRE.MatchString(slug) {
|
if !slugRE.MatchString(slug) {
|
||||||
return errInvalidSlug
|
return ErrInvalidSlug
|
||||||
}
|
}
|
||||||
if reservedSlugs[strings.ToLower(slug)] {
|
if reservedSlugs[strings.ToLower(slug)] {
|
||||||
return errReservedSlug
|
return ErrReservedSlug
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -45,5 +44,3 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return n > 0, nil
|
return n > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = fmt.Sprintf // keep fmt if unused later
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -19,7 +19,8 @@ const (
|
|||||||
argonSaltLen = 16
|
argonSaltLen = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
func argon2idHash(pw string) (string, error) {
|
// Argon2IDHash hashes a password with argon2id.
|
||||||
|
func Argon2IDHash(pw string) (string, error) {
|
||||||
salt := make([]byte, argonSaltLen)
|
salt := make([]byte, argonSaltLen)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -27,11 +28,10 @@ func argon2idHash(pw string) (string, error) {
|
|||||||
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
argonMemory, argonTime, argonThreads,
|
argonMemory, argonTime, argonThreads,
|
||||||
base64.RawStdEncoding.EncodeToString(salt),
|
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||||
base64.RawStdEncoding.EncodeToString(key)), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPassword(hash, pw string) bool {
|
func CheckPassword(hash, pw string) bool {
|
||||||
parts := strings.Split(hash, "$")
|
parts := strings.Split(hash, "$")
|
||||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
return false
|
return false
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
// Package store provides the SQLite persistence layer for palette: schema
|
||||||
|
// migrations, the Store type and all queries, and the background sweeper.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlugReservationDays is the default custom-URL reservation window (admin-tunable via settings, #40).
|
||||||
|
const SlugReservationDays = 30
|
||||||
|
|
||||||
|
// SoftDeleteGraceDays is how long soft-deleted pastes linger before hard delete.
|
||||||
|
const SoftDeleteGraceDays = 7
|
||||||
|
|
||||||
|
type Paste struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomSlug *string `json:"custom_slug,omitempty"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Language *string `json:"language,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
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:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasteRow struct {
|
||||||
|
ID string
|
||||||
|
CustomSlug sql.NullString
|
||||||
|
Content string
|
||||||
|
ContentType string
|
||||||
|
Language sql.NullString
|
||||||
|
Title sql.NullString
|
||||||
|
PasswordHash sql.NullString
|
||||||
|
ExpiresAt sql.NullInt64
|
||||||
|
BurnAfterRead bool
|
||||||
|
ReadsLimit sql.NullInt64
|
||||||
|
ReadsUsed int
|
||||||
|
Visibility string
|
||||||
|
CanID sql.NullString
|
||||||
|
CreatedAt int64
|
||||||
|
DeletedAt sql.NullInt64
|
||||||
|
ViewCount int
|
||||||
|
Size int
|
||||||
|
DeletionToken sql.NullString
|
||||||
|
ViewerID sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
type CanRow struct {
|
||||||
|
ID string
|
||||||
|
Title sql.NullString
|
||||||
|
Visibility string
|
||||||
|
PasswordHash sql.NullString
|
||||||
|
CreatedAt int64
|
||||||
|
DeletedAt sql.NullInt64
|
||||||
|
ExpiresAt sql.NullInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenStore(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &Store{db: db}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pastes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
custom_slug TEXT UNIQUE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'text/plain',
|
||||||
|
language TEXT,
|
||||||
|
title TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
|
expires_at INTEGER,
|
||||||
|
burn_after_read INTEGER DEFAULT 0,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
can_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
view_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deletion_token TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS paste_cans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlugAlphabet is the paste-id charset (no ambiguous chars).
|
||||||
|
var SlugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||||
|
|
||||||
|
// genSlug generates a random slug of length n.
|
||||||
|
func genSlug(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = cryptoRead(b)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = SlugAlphabet[int(b[i])%len(SlugAlphabet)]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validExpiry reports whether an expires_in duration is in the accepted
|
||||||
|
// window. The UI restricts presets to 1 minute - 1 year (#48); the API must
|
||||||
|
// enforce the same bounds, otherwise negative/zero/absurd durations create
|
||||||
|
// pastes that are born expired (or effectively permanent).
|
||||||
|
const (
|
||||||
|
minExpiry = time.Minute
|
||||||
|
maxExpiry = 366 * 24 * time.Hour // 1 year (+ leap day headroom)
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidExpiry(d time.Duration) bool {
|
||||||
|
return d >= minExpiry && d <= maxExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
||||||
|
id := genSlug(6)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
var expiresAt *int64
|
||||||
|
if p.ExpiresIn != nil && *p.ExpiresIn != "" {
|
||||||
|
d, err := time.ParseDuration(*p.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid expires_in: %w", err)
|
||||||
|
}
|
||||||
|
if !ValidExpiry(d) {
|
||||||
|
return nil, fmt.Errorf("expires_in must be between 1 minute and 1 year")
|
||||||
|
}
|
||||||
|
t := now + int64(d.Seconds())
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
var pwHash *string
|
||||||
|
if p.Password != nil && *p.Password != "" {
|
||||||
|
h, err := Argon2IDHash(*p.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pwHash = &h
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
||||||
|
slug := *p.CustomSlug
|
||||||
|
if err := ValidateCustomSlug(slug); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
taken, err := s.SlugTaken(slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return nil, ErrSlugTaken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #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"
|
||||||
|
}
|
||||||
|
if visibility != "public" && visibility != "unlisted" {
|
||||||
|
return nil, errors.New("visibility must be public or unlisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := p.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
var slugVal *string
|
||||||
|
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
||||||
|
slugVal = p.CustomSlug
|
||||||
|
}
|
||||||
|
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, 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
|
||||||
|
}
|
||||||
|
p.ID = id
|
||||||
|
p.CreatedAt = now
|
||||||
|
p.ExpiresAt = expiresAt
|
||||||
|
p.Visibility = visibility
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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, &r.ReadsLimit, &r.ReadsUsed)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes
|
||||||
|
WHERE visibility='public' 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 ?`, 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, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
r.CustomSlug = cs
|
||||||
|
r.Language = lang
|
||||||
|
r.Title = title
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total)
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
|
||||||
|
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content)
|
||||||
|
FROM pastes
|
||||||
|
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []PasteRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r PasteRow
|
||||||
|
var cs, lang, title sql.NullString
|
||||||
|
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
r.CustomSlug, r.Language, r.Title = cs, lang, title
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM pastes
|
||||||
|
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
|
||||||
|
viewerID, time.Now().Unix()).Scan(&total)
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MineOwner returns the stored viewer_id for a paste, or "" if none.
|
||||||
|
func (s *Store) MineOwner(id string) (string, error) {
|
||||||
|
var vid sql.NullString
|
||||||
|
err := s.db.QueryRow(`SELECT viewer_id FROM pastes WHERE id = ? AND deleted_at IS NULL`, id).Scan(&vid)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !vid.Valid {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return vid.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SoftDelete(id string) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) IncrementViews(id string) {
|
||||||
|
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace.
|
||||||
|
func (s *Store) SweepExpired() {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
|
||||||
|
grace := now - SoftDeleteGraceDays*86400
|
||||||
|
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 reservationDays days ago (custom URLs are a
|
||||||
|
// reservation, not permanent).
|
||||||
|
//
|
||||||
|
// It returns the number of pastes whose custom_slug was released.
|
||||||
|
func (s *Store) ReleaseCustomSlugs(reservationDays int) (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-int64(reservationDays)*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, reservationDays int) {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
for range t.C {
|
||||||
|
s.SweepExpired()
|
||||||
|
s.ReleaseCustomSlugs(reservationDays)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func NullStrPtr(ns sql.NullString) *string {
|
||||||
|
if ns.Valid {
|
||||||
|
return &ns.String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete removes a paste row entirely (deletion-token redeem).
|
||||||
|
func (s *Store) HardDelete(id string) {
|
||||||
|
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertCan creates a paste_can row.
|
||||||
|
func (s *Store) InsertCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64) error {
|
||||||
|
_, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCan removes an (empty/aborted) can row.
|
||||||
|
func (s *Store) DeleteCan(canID string) {
|
||||||
|
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertCanItem adds an item paste belonging to a can.
|
||||||
|
func (s *Store) InsertCanItem(canID, title, content, contentType string, language *string, expiresAt, binary *string, now int64) error {
|
||||||
|
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
|
||||||
|
_, err := s.db.Exec(`INSERT INTO pastes
|
||||||
|
(id, content, content_type, language, title, visibility, can_id, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)`,
|
||||||
|
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
|
||||||
|
_ = expiresAt
|
||||||
|
_ = binary
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetCan(id string) (*CanRow, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at
|
||||||
|
FROM paste_cans WHERE id = ? AND deleted_at IS NULL`, id)
|
||||||
|
var c CanRow
|
||||||
|
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
|
||||||
|
rows, err := s.db.Query(`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
|
||||||
|
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []PasteRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r PasteRow
|
||||||
|
if err := rows.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); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenSlug is the exported slug generator.
|
||||||
|
func GenSlug(n int) string { return genSlug(n) }
|
||||||
|
|
||||||
|
// Exec runs a raw statement (test helper).
|
||||||
|
func (s *Store) Exec(query string, args ...any) (int64, error) {
|
||||||
|
res, err := s.db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryInt runs a query returning a single integer (test helper).
|
||||||
|
func (s *Store) QueryInt(query string, args ...any) int {
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow(query, args...).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,6 @@
|
|||||||
package main
|
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
|
||||||
|
// the admin page. Templates and static assets are embedded in this package.
|
||||||
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
@@ -12,32 +14,32 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
langpkg "palette/internal/lang"
|
||||||
|
"palette/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web/templates/*.html
|
//go:embed templates/*.html
|
||||||
var tmplFS embed.FS
|
var tmplFS embed.FS
|
||||||
|
|
||||||
//go:embed web/static
|
//go:embed static
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
type webUI struct {
|
type UI struct {
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebUI() (*webUI, error) {
|
func New() (*UI, error) {
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"humanSize": humanSize,
|
"humanSize": humanSize,
|
||||||
}
|
}
|
||||||
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "web/templates/*.html")
|
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &webUI{tmpl: t}, nil
|
return &UI{tmpl: t}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanSize(n int) string {
|
func humanSize(n int) string {
|
||||||
@@ -50,20 +52,18 @@ func humanSize(n int) string {
|
|||||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||||||
}
|
}
|
||||||
|
|
||||||
func staticHandler() http.Handler {
|
func (u *UI) StaticHandler() http.Handler {
|
||||||
sub, _ := fs.Sub(staticFS, "web/static")
|
sub, _ := fs.Sub(staticFS, "static")
|
||||||
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPage(w http.ResponseWriter, name string, data any) {
|
func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := webUIInstance.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
http.Error(w, "template error: "+err.Error(), 500)
|
http.Error(w, "template error: "+err.Error(), 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var webUIInstance *webUI
|
|
||||||
|
|
||||||
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
||||||
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
||||||
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
||||||
@@ -87,22 +87,6 @@ func unlockToken(id string) string {
|
|||||||
return hex.EncodeToString(mac.Sum(nil))
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
renderPage(w, "new.html", map[string]any{"Page": "new"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleHistoryPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
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 {
|
||||||
@@ -129,7 +113,31 @@ func expiryString(expiresAt int64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
|
// Handlers is the set of store callbacks the web pages need. The web package
|
||||||
|
// renders HTML; all queries go through the store.
|
||||||
|
type Handlers struct {
|
||||||
|
UI *UI
|
||||||
|
Store *store.Store
|
||||||
|
ViewerID func(r *http.Request) string
|
||||||
|
BurnWindowMin func() int
|
||||||
|
RateLimitOK func(id string, r *http.Request) bool // per-paste unlock limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) rateLimitUnlock(id string, r *http.Request) bool {
|
||||||
|
if h.RateLimitOK != nil {
|
||||||
|
return h.RateLimitOK(id, r)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
||||||
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSecs))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"error":"rate limit exceeded"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.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++ {
|
||||||
@@ -154,7 +162,7 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
|
|||||||
"HasPassword": row.PasswordHash.Valid,
|
"HasPassword": row.PasswordHash.Valid,
|
||||||
"BurnAfterRead": row.BurnAfterRead,
|
"BurnAfterRead": row.BurnAfterRead,
|
||||||
"CustomSlug": row.CustomSlug.String,
|
"CustomSlug": row.CustomSlug.String,
|
||||||
"ContentHTML": template.HTML(highlightCode(row.Content, row.Language.String)), // safe: highlightCode escapes all non-span text
|
"ContentHTML": template.HTML(langpkg.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,
|
||||||
@@ -167,18 +175,18 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
|
|||||||
"ExpiresIn": expIn,
|
"ExpiresIn": expIn,
|
||||||
"DeletionToken": deletionToken,
|
"DeletionToken": deletionToken,
|
||||||
"ReadsLimit": row.ReadsLimit.Valid,
|
"ReadsLimit": row.ReadsLimit.Valid,
|
||||||
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
|
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
|
||||||
"ReadsTotal": int(row.ReadsLimit.Int64),
|
"ReadsTotal": int(row.ReadsLimit.Int64),
|
||||||
"JustCreated": justCreated,
|
"JustCreated": justCreated,
|
||||||
"Host": "this host",
|
"Host": "this host",
|
||||||
}
|
}
|
||||||
renderPage(w, "paste.html", data)
|
h.renderPage(w, "paste.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePastePage renders the paste view; supports both ID and custom slug.
|
// HandlePasteView renders the paste view; supports both ID and custom slug.
|
||||||
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := r.PathValue("id")
|
||||||
row, err := a.store.GetPaste(id)
|
row, err := h.Store.GetPaste(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", 500)
|
http.Error(w, "db error", 500)
|
||||||
return
|
return
|
||||||
@@ -194,13 +202,13 @@ 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) {
|
if !h.rateLimitUnlock(row.ID, r) {
|
||||||
writeRateLimited(w, 60)
|
h.writeRateLimited(w, 60)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
pw := r.FormValue("password")
|
pw := r.FormValue("password")
|
||||||
if pw != "" && checkPassword(row.PasswordHash.String, pw) {
|
if pw != "" && store.CheckPassword(row.PasswordHash.String, pw) {
|
||||||
// #34: the unlock cookie must be bound to this specific paste and
|
// #34: the unlock cookie must be bound to this specific paste and
|
||||||
// unforgable. A static value ("1") let anyone bypass the password
|
// unforgable. A static value ("1") let anyone bypass the password
|
||||||
// by setting pw_<id>=1 for any paste id. The token is an HMAC of
|
// by setting pw_<id>=1 for any paste id. The token is an HMAC of
|
||||||
@@ -217,16 +225,16 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.renderPaste(w, row, false, "", nil)
|
h.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), "CreatedAtUnix": row.CreatedAt})
|
h.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 — must carry the valid per-paste unlock token (#34)
|
// check cookie — must carry the valid per-paste unlock token (#34)
|
||||||
c, err := r.Cookie("pw_" + row.ID)
|
c, err := r.Cookie("pw_" + row.ID)
|
||||||
if err != nil || c.Value != unlockToken(row.ID) {
|
if err != nil || c.Value != unlockToken(row.ID) {
|
||||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,17 +250,43 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ever moved via /raw and the API-stored count stayed at 0 (#33).
|
// ever moved via /raw and the API-stored count stayed at 0 (#33).
|
||||||
// The just-created banner render does not count as a view.
|
// The just-created banner render does not count as a view.
|
||||||
if !justCreated {
|
if !justCreated {
|
||||||
a.store.IncrementViews(row.ID)
|
h.Store.IncrementViews(row.ID)
|
||||||
}
|
}
|
||||||
// #49: burn-after-N-reads budget (per-viewer, 15-minute dedupe window).
|
// #49: burn-after-N-reads budget (per-viewer dedupe window).
|
||||||
// Just-created first render does not count as a read for the creator.
|
// Just-created first render does not count as a read for the creator.
|
||||||
if !justCreated {
|
if !justCreated {
|
||||||
rem, _ := a.store.registerRead(row, currentViewerID(r))
|
rem, _ := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
|
||||||
a.renderPaste(w, row, false, "", rem)
|
h.renderPaste(w, row, false, "", rem)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// only pass the token to the template right after creation
|
// only pass the token to the template right after creation
|
||||||
a.renderPaste(w, row, true, token, nil)
|
h.renderPaste(w, row, true, token, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = strconv.Itoa
|
// HandleNewPage serves /new.
|
||||||
|
func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHistoryPage serves /history.
|
||||||
|
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSettingsPage serves /settings.
|
||||||
|
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "settings.html", map[string]any{"Page": "settings"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMinePage serves /mine.
|
||||||
|
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAdminPage serves /admin.
|
||||||
|
func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "admin.html", map[string]any{"Page": "admin"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers builds a web.Handlers bound to this UI.
|
||||||
|
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
|
||||||
@@ -1,833 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed web/templates/* web/static/*
|
|
||||||
var webFS embed.FS
|
|
||||||
|
|
||||||
const (
|
|
||||||
softDeleteGraceDays = 7
|
|
||||||
customSlugReservationDays = 30
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Addr string
|
|
||||||
DBPath string
|
|
||||||
MaxTextBytes int64
|
|
||||||
MaxItemBytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Paste struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CustomSlug *string `json:"custom_slug,omitempty"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
Language *string `json:"language,omitempty"`
|
|
||||||
Title *string `json:"title,omitempty"`
|
|
||||||
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:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PasteRow struct {
|
|
||||||
ID string
|
|
||||||
CustomSlug sql.NullString
|
|
||||||
Content string
|
|
||||||
ContentType string
|
|
||||||
Language sql.NullString
|
|
||||||
Title sql.NullString
|
|
||||||
PasswordHash sql.NullString
|
|
||||||
ExpiresAt sql.NullInt64
|
|
||||||
BurnAfterRead bool
|
|
||||||
ReadsLimit sql.NullInt64
|
|
||||||
ReadsUsed int
|
|
||||||
Visibility string
|
|
||||||
CanID sql.NullString
|
|
||||||
CreatedAt int64
|
|
||||||
DeletedAt sql.NullInt64
|
|
||||||
ViewCount int
|
|
||||||
Size int
|
|
||||||
DeletionToken sql.NullString
|
|
||||||
ViewerID sql.NullString
|
|
||||||
}
|
|
||||||
|
|
||||||
type CanRow struct {
|
|
||||||
ID string
|
|
||||||
Title sql.NullString
|
|
||||||
Visibility string
|
|
||||||
PasswordHash sql.NullString
|
|
||||||
CreatedAt int64
|
|
||||||
DeletedAt sql.NullInt64
|
|
||||||
ExpiresAt sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func OpenStore(path string) (*Store, error) {
|
|
||||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s := &Store{db: db}
|
|
||||||
if err := s.migrate(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS pastes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
custom_slug TEXT UNIQUE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
content_type TEXT NOT NULL DEFAULT 'text/plain',
|
|
||||||
language TEXT,
|
|
||||||
title TEXT,
|
|
||||||
password_hash TEXT,
|
|
||||||
expires_at INTEGER,
|
|
||||||
burn_after_read INTEGER DEFAULT 0,
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'public',
|
|
||||||
can_id TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
view_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
deletion_token TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
|
|
||||||
CREATE TABLE IF NOT EXISTS paste_cans (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'public',
|
|
||||||
password_hash TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
expires_at INTEGER
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var slugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
|
|
||||||
var httpClient = &http.Client{}
|
|
||||||
|
|
||||||
func genSlug(n int) string {
|
|
||||||
b := make([]byte, n)
|
|
||||||
_, _ = cryptorandRead(b)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = slugAlphabet[int(b[i])%len(slugAlphabet)]
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cryptorandRead wraps crypto/rand
|
|
||||||
func cryptorandRead(b []byte) (int, error) {
|
|
||||||
return cryptoRead(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validExpiry reports whether an expires_in duration is in the accepted
|
|
||||||
// window. The UI restricts presets to 1 minute - 1 year (#48); the API must
|
|
||||||
// enforce the same bounds, otherwise negative/zero/absurd durations create
|
|
||||||
// pastes that are born expired (or effectively permanent).
|
|
||||||
const (
|
|
||||||
minExpiry = time.Minute
|
|
||||||
maxExpiry = 366 * 24 * time.Hour // 1 year (+ leap day headroom)
|
|
||||||
)
|
|
||||||
|
|
||||||
func validExpiry(d time.Duration) bool {
|
|
||||||
return d >= minExpiry && d <= maxExpiry
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|
||||||
id := genSlug(6)
|
|
||||||
now := time.Now().Unix()
|
|
||||||
|
|
||||||
var expiresAt *int64
|
|
||||||
if p.ExpiresIn != nil && *p.ExpiresIn != "" {
|
|
||||||
d, err := time.ParseDuration(*p.ExpiresIn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid expires_in: %w", err)
|
|
||||||
}
|
|
||||||
if !validExpiry(d) {
|
|
||||||
return nil, fmt.Errorf("expires_in must be between 1 minute and 1 year")
|
|
||||||
}
|
|
||||||
t := now + int64(d.Seconds())
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
var pwHash *string
|
|
||||||
if p.Password != nil && *p.Password != "" {
|
|
||||||
h, err := hashPassword(*p.Password)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pwHash = &h
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
|
||||||
slug := *p.CustomSlug
|
|
||||||
if err := ValidateCustomSlug(slug); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
taken, err := s.SlugTaken(slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if taken {
|
|
||||||
return nil, errSlugTaken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #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"
|
|
||||||
}
|
|
||||||
if visibility != "public" && visibility != "unlisted" {
|
|
||||||
return nil, errors.New("visibility must be public or unlisted")
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := p.ContentType
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = "text/plain"
|
|
||||||
}
|
|
||||||
|
|
||||||
var slugVal *string
|
|
||||||
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
|
||||||
slugVal = p.CustomSlug
|
|
||||||
}
|
|
||||||
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, 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
|
|
||||||
}
|
|
||||||
p.ID = id
|
|
||||||
p.CreatedAt = now
|
|
||||||
p.ExpiresAt = expiresAt
|
|
||||||
p.Visibility = visibility
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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, 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, &r.ReadsLimit, &r.ReadsUsed)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &r, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
|
||||||
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes
|
|
||||||
WHERE visibility='public' 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 ?`, 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, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
r.CustomSlug = cs
|
|
||||||
r.Language = lang
|
|
||||||
r.Title = title
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
var total int
|
|
||||||
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total)
|
|
||||||
return out, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
|
|
||||||
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
|
|
||||||
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content)
|
|
||||||
FROM pastes
|
|
||||||
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
|
||||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []PasteRow
|
|
||||||
for rows.Next() {
|
|
||||||
var r PasteRow
|
|
||||||
var cs, lang, title sql.NullString
|
|
||||||
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
r.CustomSlug, r.Language, r.Title = cs, lang, title
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
var total int
|
|
||||||
s.db.QueryRow(`SELECT COUNT(*) FROM pastes
|
|
||||||
WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
|
|
||||||
viewerID, time.Now().Unix()).Scan(&total)
|
|
||||||
return out, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MineOwner returns the stored viewer_id for a paste, or "" if none.
|
|
||||||
func (s *Store) MineOwner(id string) (string, error) {
|
|
||||||
var vid sql.NullString
|
|
||||||
err := s.db.QueryRow(`SELECT viewer_id FROM pastes WHERE id = ? AND deleted_at IS NULL`, id).Scan(&vid)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !vid.Valid {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return vid.String, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SoftDelete(id string) error {
|
|
||||||
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) IncrementViews(id string) {
|
|
||||||
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace.
|
|
||||||
func (s *Store) SweepExpired() {
|
|
||||||
now := time.Now().Unix()
|
|
||||||
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
|
|
||||||
grace := now - softDeleteGraceDays*86400
|
|
||||||
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) {
|
|
||||||
go func() {
|
|
||||||
t := time.NewTicker(every)
|
|
||||||
for range t.C {
|
|
||||||
s.SweepExpired()
|
|
||||||
s.ReleaseCustomSlugs()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashPassword(pw string) (string, error) {
|
|
||||||
// argon2id
|
|
||||||
return argon2idHash(pw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolToInt(b bool) int {
|
|
||||||
if b {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullStrPtr(ns sql.NullString) *string {
|
|
||||||
if ns.Valid {
|
|
||||||
return &ns.String
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiServer struct {
|
|
||||||
store *Store
|
|
||||||
cfg Config
|
|
||||||
settings *settingsStore
|
|
||||||
adminKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) routes() http.Handler {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Use(middleware.Recoverer)
|
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
|
||||||
r.Use(viewerCookieMiddleware)
|
|
||||||
|
|
||||||
// admin (#40): HTML page is open (key entry via form); API is key-guarded
|
|
||||||
r.Get("/admin", a.handleAdminPage)
|
|
||||||
r.Get("/admin/api/settings", a.adminAuth(a.handleAdminGetSettings, a.adminKey))
|
|
||||||
r.Post("/admin/api/settings", a.adminAuth(a.handleAdminPostSettings, a.adminKey))
|
|
||||||
|
|
||||||
// API
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
|
||||||
r.Post("/pastes", a.handleCreatePaste)
|
|
||||||
r.Get("/pastes/{id}", a.handleGetPaste)
|
|
||||||
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
|
||||||
r.Get("/mine", a.handleListMine)
|
|
||||||
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
|
||||||
r.Get("/public", a.handleListPublic)
|
|
||||||
r.Post("/guess-language", a.handleGuessLang)
|
|
||||||
r.Post("/pastes/can", a.handleCreateCan)
|
|
||||||
r.Get("/cans/{id}", a.handleGetCan)
|
|
||||||
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
// can page
|
|
||||||
r.Get("/can/{id}", a.handleCanPage)
|
|
||||||
|
|
||||||
// raw
|
|
||||||
r.Get("/raw/{id}", a.handleRaw)
|
|
||||||
|
|
||||||
// web pages
|
|
||||||
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
|
|
||||||
r.Get("/new", a.handleNewPage)
|
|
||||||
r.Get("/history", a.handleHistoryPage)
|
|
||||||
r.Get("/settings", a.handleSettingsPage)
|
|
||||||
r.Get("/mine", a.handleMinePage)
|
|
||||||
r.Handle("/static/*", staticHandler())
|
|
||||||
r.Get("/unlock/{id}", a.handlePasteView)
|
|
||||||
r.Post("/unlock/{id}", a.handlePasteView)
|
|
||||||
r.Get("/{id}", a.handlePasteView)
|
|
||||||
r.Post("/{id}", a.handlePasteView)
|
|
||||||
|
|
||||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeErr(w, 404, "not found")
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// viewerCookieMiddleware ensures every request carries an anonymous browser id
|
|
||||||
// cookie ("vwr"); sets one on the response if absent. Used by /mine (#37, #49).
|
|
||||||
func viewerCookieMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if c, err := r.Cookie("vwr"); err != nil || c.Value == "" {
|
|
||||||
id := genSlug(16)
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "vwr", Value: id, Path: "/",
|
|
||||||
MaxAge: 31536000, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
r.AddCookie(&http.Cookie{Name: "vwr", Value: id})
|
|
||||||
// remember that this cookie was minted here, not sent by the client
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), vwrMintedKey, true))
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type vwrMintedKeyType struct{}
|
|
||||||
|
|
||||||
var vwrMintedKey vwrMintedKeyType
|
|
||||||
|
|
||||||
func currentViewerID(r *http.Request) string {
|
|
||||||
if c, err := r.Cookie("vwr"); err == nil {
|
|
||||||
return c.Value
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// viewerSentCookie reports whether the client itself sent a vwr cookie
|
|
||||||
// (as opposed to the middleware minting one for this request).
|
|
||||||
func viewerSentCookie(r *http.Request) bool {
|
|
||||||
if _, err := r.Cookie("vwr"); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, minted := r.Context().Value(vwrMintedKey).(bool)
|
|
||||||
return !minted
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
|
||||||
setRateLimitHeaders(w, 1, 5)
|
|
||||||
if !rateLimitCreate(r) {
|
|
||||||
writeRateLimited(w, 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var p Paste
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
||||||
writeErr(w, 400, "invalid json body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(p.Content) == "" {
|
|
||||||
writeErr(w, 400, "content is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if int64(len(p.Content)) > a.settings.get().MaxContentBytes { // #40: admin-tunable
|
|
||||||
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.settings.get().MaxContentBytes))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// #40: admin-configurable default expiry
|
|
||||||
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && a.settings.get().DefaultExpiry != "" {
|
|
||||||
def := a.settings.get().DefaultExpiry
|
|
||||||
p.ExpiresIn = &def
|
|
||||||
}
|
|
||||||
p.ViewerID = currentViewerID(r)
|
|
||||||
created, err := a.store.CreatePaste(&p)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, 201, map[string]any{
|
|
||||||
"id": created.ID,
|
|
||||||
"deletion_token": created.DeletionToken,
|
|
||||||
"url": "/" + created.ID,
|
|
||||||
"raw_url": "/raw/" + created.ID,
|
|
||||||
"api_url": "/api/pastes/" + created.ID,
|
|
||||||
"expires_at": created.ExpiresAt,
|
|
||||||
"created_at": created.CreatedAt,
|
|
||||||
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, 500, "db error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row == nil {
|
|
||||||
writeErr(w, 404, "paste not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
||||||
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")
|
|
||||||
if pw == "" {
|
|
||||||
pw = r.URL.Query().Get("password")
|
|
||||||
}
|
|
||||||
if pw == "" || !checkPassword(row.PasswordHash.String, pw) {
|
|
||||||
writeErr(w, 401, "password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nullPtr := func(ns sql.NullString) *string {
|
|
||||||
if ns.Valid {
|
|
||||||
return &ns.String
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil || row == nil {
|
|
||||||
writeErr(w, 404, "paste not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// viewer-cookie delete enforcement (#37): only the browser that created
|
|
||||||
// the paste (matching vwr) may delete it via this endpoint. Requests with
|
|
||||||
// no client-sent vwr cookie (plain API clients) are unaffected.
|
|
||||||
vid := currentViewerID(r)
|
|
||||||
if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid {
|
|
||||||
writeErr(w, 403, "not your paste")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := a.store.SoftDelete(row.ID); err != nil {
|
|
||||||
writeErr(w, 500, "db error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListMine serves /api/mine: pastes created from this browser (#37).
|
|
||||||
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vid := currentViewerID(r)
|
|
||||||
if vid == "" {
|
|
||||||
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
||||||
rows, total, err := a.store.ListMine(vid, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, 500, "db error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]map[string]any, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title)
|
|
||||||
items = append(items, map[string]any{
|
|
||||||
"id": row.ID, "title": title, "language": lang,
|
|
||||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
|
||||||
"custom_slug": nullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 25
|
|
||||||
}
|
|
||||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
||||||
rows, total, err := a.store.ListPublic(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),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil || row == nil {
|
|
||||||
http.Error(w, "not found", 404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
||||||
http.Error(w, "paste expired", 404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.PasswordHash.Valid {
|
|
||||||
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))
|
|
||||||
// #34: content_type is attacker-controlled via the create API. Serving it
|
|
||||||
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
|
||||||
// render as active content on this origin when fetched from /raw —
|
|
||||||
// stored XSS. Only pass through a fixed safe set; anything else is
|
|
||||||
// served as plain text with nosniff.
|
|
||||||
ct := row.ContentType
|
|
||||||
if !safeRawContentType(ct) {
|
|
||||||
ct = "text/plain; charset=utf-8"
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", ct)
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
a.store.IncrementViews(row.ID)
|
|
||||||
w.Write([]byte(row.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeRawContentType reports whether ct is in the fixed set of types that are
|
|
||||||
// safe to serve verbatim on /raw (no active-content execution contexts).
|
|
||||||
func safeRawContentType(ct string) bool {
|
|
||||||
base := ct
|
|
||||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
||||||
base = ct[:i]
|
|
||||||
}
|
|
||||||
base = strings.ToLower(strings.TrimSpace(base))
|
|
||||||
switch base {
|
|
||||||
case "text/plain", "text/markdown", "text/x-markdown",
|
|
||||||
"application/json", "application/pdf",
|
|
||||||
"image/png", "image/jpeg", "image/gif", "image/webp",
|
|
||||||
"application/octet-stream":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
can, err := a.store.GetCan(id)
|
|
||||||
if err != nil || can == nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, _ := a.store.ListCanItems(can.ID)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
|
|
||||||
for _, it := range items {
|
|
||||||
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "</ul></body></html>")
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullStrOr(ns sql.NullString, def string) string {
|
|
||||||
if ns.Valid {
|
|
||||||
return ns.String
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleHome(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
|
||||||
w.Write([]byte("palette pastebin api\nPOST /api/pastes {\"content\": \"...\", \"language\": \"go\", \"expires_in\": \"168h\", \"password\": \"...\", \"visibility\": \"public\"}\nGET /api/pastes/{id}\nGET /api/public?limit=25&offset=0\nGET /raw/{id}\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handlePastePage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
// if it looks like an asset request, 404
|
|
||||||
if strings.Contains(id, ".") {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil || row == nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.store.IncrementViews(row.ID)
|
|
||||||
// render basic view; full templates come later with frontend work
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, "<!doctype html><html><head><title>%s — palette</title></head><body><pre>%s</pre></body></html>",
|
|
||||||
row.ID, templateEsc(row.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func templateEsc(s string) string {
|
|
||||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
|
||||||
return r.Replace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// #40: --reset-admin-key regenerates the admin key and exits.
|
|
||||||
if len(os.Args) > 1 && (os.Args[1] == "--reset-admin-key") {
|
|
||||||
handleResetAdminKey(envOr("PALETTE_DB", "palette.db"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := Config{
|
|
||||||
Addr: envOr("PALETTE_ADDR", ":8080"),
|
|
||||||
DBPath: envOr("PALETTE_DB", "palette.db"),
|
|
||||||
MaxTextBytes: int64(envIntOr("PALETTE_MAX_TEXT", 5*1024*1024)),
|
|
||||||
MaxItemBytes: int64(envIntOr("PALETTE_MAX_ITEM", 25*1024*1024)),
|
|
||||||
}
|
|
||||||
store, err := OpenStore(cfg.DBPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
store.StartSweeper(time.Minute)
|
|
||||||
|
|
||||||
adminKey, err := resolveAdminKey(cfg.DBPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
ss := loadSettingsStore(cfg.DBPath, cfg)
|
|
||||||
|
|
||||||
ui, err := NewWebUI()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
webUIInstance = ui
|
|
||||||
srv := &apiServer{store: store, cfg: cfg, settings: ss, adminKey: adminKey}
|
|
||||||
globalSettingsFn = func() Settings { return ss.get() }
|
|
||||||
log.Printf("palette listening on %s", cfg.Addr)
|
|
||||||
log.Fatal(http.ListenAndServe(cfg.Addr, srv.routes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOr(k, d string) string {
|
|
||||||
if v := os.Getenv(k); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func envIntOr(k string, d int) int {
|
|
||||||
if v := os.Getenv(k); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user