diff --git a/.gitignore b/.gitignore
index 1ccb3ed..e4f2435 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ palette
palette.db
palette.db-shm
palette.db-wal
+admin-key
+settings.json
diff --git a/Dockerfile b/Dockerfile
index 1512eb6..770886b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,7 +9,7 @@ COPY go.mod go.sum ./
RUN go mod download
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 ----
FROM alpine:3.20
diff --git a/burn.go b/burn.go
deleted file mode 100644
index fb8acac..0000000
--- a/burn.go
+++ /dev/null
@@ -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"})
-}
diff --git a/admin.go b/internal/api/admin.go
similarity index 85%
rename from admin.go
rename to internal/api/admin.go
index 9687e5f..bdde0da 100644
--- a/admin.go
+++ b/internal/api/admin.go
@@ -1,6 +1,7 @@
-package main
+package api
import (
+ "palette/internal/store"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
@@ -37,8 +38,8 @@ func defaultSettings(cfg Config) Settings {
RateLimitPerMinute: 60, // 1 req/sec refill
MaxContentBytes: cfg.MaxTextBytes,
DefaultExpiry: "", // no default: pastes are permanent unless expires_in given
- CustomSlugReservationDays: customSlugReservationDays,
- BurnViewerWindowMinutes: readWindowMinutes,
+ CustomSlugReservationDays: 30,
+ BurnViewerWindowMinutes: 15,
}
}
@@ -50,7 +51,8 @@ type settingsStore struct {
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")
ss := &settingsStore{cur: defaultSettings(cfg), path: p}
if b, err := os.ReadFile(p); err == nil {
@@ -95,7 +97,7 @@ func (ss *settingsStore) set(s Settings) error {
}
if 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)")
}
}
@@ -111,7 +113,8 @@ func (ss *settingsStore) set(s Settings) error {
// resetAdminKeyFile deletes the persisted admin key file (if any) and returns
// 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")
os.Remove(p)
return p
@@ -120,7 +123,10 @@ func resetAdminKeyFile(dbPath string) string {
// 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) {
+// 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 != "" {
return v, nil
}
@@ -142,9 +148,11 @@ func resolveAdminKey(dbPath string) (string, error) {
// handleResetAdminKey implements the --reset-admin-key flag: delete the key
// file, generate a fresh key, print it.
-func handleResetAdminKey(dbPath string) {
- p := resetAdminKeyFile(dbPath)
- key, err := resolveAdminKey(dbPath)
+// HandleResetAdminKey implements the --reset-admin-key flag: delete the key
+// file, generate a fresh key, print it.
+func HandleResetAdminKey(dbPath string) {
+ p := ResetAdminKeyFile(dbPath)
+ key, err := ResolveAdminKey(dbPath)
if err != nil {
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) {
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())
}
+
+// Get returns the current settings (exported for cmd wiring).
+func (ss *settingsStore) Get() Settings { return ss.get() }
diff --git a/admin_test.go b/internal/api/admin_test.go
similarity index 93%
rename from admin_test.go
rename to internal/api/admin_test.go
index dbbaed3..8dc2dbc 100644
--- a/admin_test.go
+++ b/internal/api/admin_test.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"encoding/json"
@@ -10,10 +10,10 @@ import (
)
// 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()
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)
return ss
}
@@ -65,7 +65,7 @@ func TestAdminEnvKeyPrecedence(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "palette.db")
t.Setenv("PALETTE_ADMIN_KEY", "envkey1234567890abcdef")
- key, err := resolveAdminKey(dbPath)
+ key, err := ResolveAdminKey(dbPath)
if err != nil {
t.Fatal(err)
}
@@ -78,7 +78,7 @@ func TestAdminEnvKeyPrecedence(t *testing.T) {
// unset env: file takes over
os.Unsetenv("PALETTE_ADMIN_KEY")
- key2, err := resolveAdminKey(dbPath)
+ key2, err := ResolveAdminKey(dbPath)
if err != nil {
t.Fatal(err)
}
@@ -89,7 +89,7 @@ func TestAdminEnvKeyPrecedence(t *testing.T) {
t.Fatalf("admin-key file perms: %v err %v", fi, err)
}
// reuse on subsequent boots
- key3, _ := resolveAdminKey(dbPath)
+ key3, _ := ResolveAdminKey(dbPath)
if key3 != key2 {
t.Fatal("persisted key not reused")
}
@@ -158,10 +158,10 @@ func TestAdminResetKey(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "palette.db")
os.Unsetenv("PALETTE_ADMIN_KEY")
- key1, _ := resolveAdminKey(dbPath)
+ key1, _ := ResolveAdminKey(dbPath)
// direct invocation of the reset behavior
- resetAdminKeyFile(dbPath)
- key2, _ := resolveAdminKey(dbPath)
+ ResetAdminKeyFile(dbPath)
+ key2, _ := ResolveAdminKey(dbPath)
if key1 == key2 {
t.Fatal("reset did not regenerate key")
}
diff --git a/internal/api/burn.go b/internal/api/burn.go
new file mode 100644
index 0000000..a425ac2
--- /dev/null
+++ b/internal/api/burn.go
@@ -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"})
+}
diff --git a/burn_test.go b/internal/api/burn_test.go
similarity index 96%
rename from burn_test.go
rename to internal/api/burn_test.go
index d9f9331..cdaa2ef 100644
--- a/burn_test.go
+++ b/internal/api/burn_test.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"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
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 {
t.Fatal("row still exists after redeem")
}
diff --git a/burnreads_test.go b/internal/api/burnreads_test.go
similarity index 93%
rename from burnreads_test.go
rename to internal/api/burnreads_test.go
index 2eb6f6a..bd74e69 100644
--- a/burnreads_test.go
+++ b/internal/api/burnreads_test.go
@@ -1,6 +1,8 @@
-package main
+package api
import (
+ "palette/internal/store"
+
"encoding/json"
"net/http"
"net/http/httptest"
@@ -87,19 +89,19 @@ func TestBurnReadsWindowExpiryRecounts(t *testing.T) {
id := createBurnReads(t, h, 2)
base := time.Now()
- timeNow = func() time.Time { return base }
- t.Cleanup(func() { timeNow = time.Now })
+ store.TimeNow = func() time.Time { return base }
+ t.Cleanup(func() { store.TimeNow = time.Now })
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
// 10 minutes later: still within window, deduped
- timeNow = func() time.Time { return base.Add(10 * time.Minute) }
+ store.TimeNow = func() time.Time { return base.Add(10 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read within window: %d", rec.Code)
}
// 20 minutes after first read: window expired, counts as read 2
- timeNow = func() time.Time { return base.Add(20 * time.Minute) }
+ store.TimeNow = func() time.Time { return base.Add(20 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read after window expected 200, got %d", rec.Code)
}
@@ -130,13 +132,6 @@ func TestBurnReadsDefaultOne(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)
h := s.routes()
id := createBurnReads(t, h, 2)
diff --git a/cans.go b/internal/api/cans.go
similarity index 69%
rename from cans.go
rename to internal/api/cans.go
index fb622c6..c014e44 100644
--- a/cans.go
+++ b/internal/api/cans.go
@@ -1,7 +1,7 @@
-package main
+package api
import (
- "database/sql"
+ "palette/internal/store"
"encoding/json"
"fmt"
"io"
@@ -45,7 +45,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
}
var pwHash *string
if password != "" {
- h, err := hashPassword(password)
+ h, err := store.Argon2IDHash(password)
if err != nil {
writeErr(w, 500, "hash error")
return
@@ -53,9 +53,8 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
pwHash = &h
}
- canID := genSlug(8)
- _, err := a.store.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
- VALUES (?,?,?,?,?,?,?)`, canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
+ canID := store.GenSlug(8)
+ err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
if err != nil {
writeErr(w, 500, "db error")
return
@@ -76,7 +75,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
return
}
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")
return
}
@@ -104,7 +103,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
return
}
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")
return
}
@@ -114,7 +113,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
}
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)")
return
}
@@ -144,45 +143,7 @@ func detectContentType(name string, content []byte) string {
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) {
id := chi.URLParam(r, "id")
@@ -204,7 +165,7 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
if pw == "" {
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")
return
}
@@ -224,12 +185,12 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
metas := make([]itemMeta, 0, len(items))
for _, it := range items {
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,
})
}
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,
})
}
@@ -253,7 +214,7 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
if pw == "" {
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")
return
}
diff --git a/cans_test.go b/internal/api/cans_test.go
similarity index 99%
rename from cans_test.go
rename to internal/api/cans_test.go
index e704aad..e6ac0ec 100644
--- a/cans_test.go
+++ b/internal/api/cans_test.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"bytes"
diff --git a/customslug_test.go b/internal/api/customslug_test.go
similarity index 95%
rename from customslug_test.go
rename to internal/api/customslug_test.go
index aec2dde..c373d1c 100644
--- a/customslug_test.go
+++ b/internal/api/customslug_test.go
@@ -1,6 +1,8 @@
-package main
+package api
import (
+ "palette/internal/store"
+
"encoding/json"
"net/http/httptest"
"strings"
@@ -61,7 +63,7 @@ func TestCustomSlugValidation(t *testing.T) {
func TestSlugCollisionWithAutoID(t *testing.T) {
s := testServer(t)
// 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 {
t.Fatal(err)
}
diff --git a/internal/api/guess.go b/internal/api/guess.go
new file mode 100644
index 0000000..58aa3e4
--- /dev/null
+++ b/internal/api/guess.go
@@ -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})
+}
diff --git a/main_test.go b/internal/api/main_test.go
similarity index 91%
rename from main_test.go
rename to internal/api/main_test.go
index cec8caa..1ece18c 100644
--- a/main_test.go
+++ b/internal/api/main_test.go
@@ -1,6 +1,9 @@
-package main
+package api
import (
+ "palette/internal/store"
+ "palette/internal/web"
+
"encoding/json"
"net/http"
"net/http/httptest"
@@ -12,22 +15,19 @@ import (
func testServer(t *testing.T) *apiServer {
t.Helper()
globalLimiter = newLimiter() // fresh buckets per test
- if webUIInstance == nil {
- ui, err := NewWebUI()
- if err != nil {
- t.Fatal(err)
- }
- webUIInstance = ui
+ ui, err := web.New()
+ if err != nil {
+ t.Fatal(err)
}
- store, err := OpenStore(":memory:")
+ st, err := store.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}
- ss := newTestSettingsStore(t, cfg)
+ ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
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) {
@@ -188,13 +188,13 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
s.store.SoftDelete(created.ID)
// simulate grace elapsed
- past := time.Now().Unix() - (softDeleteGraceDays+1)*86400
- s.store.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
+ past := time.Now().Unix() - (store.SoftDeleteGraceDays+1)*86400
+ s.store.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
s.store.SweepExpired()
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 {
t.Fatal("expected hard delete after grace period")
}
@@ -202,9 +202,9 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
func TestSlugCharset(t *testing.T) {
for i := 0; i < 100; i++ {
- s := genSlug(6)
+ s := store.GenSlug(6)
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)
}
}
diff --git a/mine_test.go b/internal/api/mine_test.go
similarity index 92%
rename from mine_test.go
rename to internal/api/mine_test.go
index 026f0d9..233e5a5 100644
--- a/mine_test.go
+++ b/internal/api/mine_test.go
@@ -1,6 +1,9 @@
-package main
+package api
import (
+ "palette/internal/store"
+ "palette/internal/web"
+
"encoding/json"
"net/http"
"net/http/httptest"
@@ -40,20 +43,19 @@ func viewerCookieFor(t *testing.T, h http.Handler, path string) string {
func TestMineCreateListDelete(t *testing.T) {
globalLimiter = newLimiter() // fresh rate-limit buckets
- webUI, err := NewWebUI()
+ st, err := store.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
- webUIInstance = webUI
- store, err := OpenStore(":memory:")
+ ui, err := web.New()
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
- ss := newTestSettingsStore(t, cfg)
+ ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
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()
alice := viewerCookieFor(t, h, "/history")
diff --git a/pentest_cookie_test.go b/internal/api/pentest_cookie_test.go
similarity index 98%
rename from pentest_cookie_test.go
rename to internal/api/pentest_cookie_test.go
index c2183e0..5d45a8f 100644
--- a/pentest_cookie_test.go
+++ b/internal/api/pentest_cookie_test.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"encoding/json"
diff --git a/pentest_helpers_test.go b/internal/api/pentest_helpers_test.go
similarity index 95%
rename from pentest_helpers_test.go
rename to internal/api/pentest_helpers_test.go
index f472474..607d7a3 100644
--- a/pentest_helpers_test.go
+++ b/internal/api/pentest_helpers_test.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"encoding/json"
diff --git a/pentest_rawct_test.go b/internal/api/pentest_rawct_test.go
similarity index 99%
rename from pentest_rawct_test.go
rename to internal/api/pentest_rawct_test.go
index 2ab7b12..3e08108 100644
--- a/pentest_rawct_test.go
+++ b/internal/api/pentest_rawct_test.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"net/http/httptest"
diff --git a/ratelimit.go b/internal/api/ratelimit.go
similarity index 96%
rename from ratelimit.go
rename to internal/api/ratelimit.go
index eaac2e8..c31aec0 100644
--- a/ratelimit.go
+++ b/internal/api/ratelimit.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"net/http"
@@ -70,8 +70,7 @@ func globalSettings() Settings {
}
// rateLimitCreate uses the admin-tunable burst and per-minute refill (#40).
-func rateLimitCreate(r *http.Request) bool {
- s := globalSettings()
+func rateLimitCreate(r *http.Request, s Settings) bool {
return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst)
}
diff --git a/ratelimit_test.go b/internal/api/ratelimit_test.go
similarity index 93%
rename from ratelimit_test.go
rename to internal/api/ratelimit_test.go
index c3593a1..731a879 100644
--- a/ratelimit_test.go
+++ b/internal/api/ratelimit_test.go
@@ -1,6 +1,10 @@
-package main
+package api
import (
+ "palette/internal/lang"
+ "palette/internal/store"
+ "palette/internal/web"
+
"bytes"
"encoding/json"
"net/http"
@@ -12,22 +16,19 @@ import (
func newTestServer(t *testing.T) *apiServer {
t.Helper()
globalLimiter = newLimiter() // fresh buckets per test
- store, err := OpenStore(t.TempDir() + "/test.db")
+ st, err := store.OpenStore(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
- if webUIInstance == nil {
- ui, err := NewWebUI()
- if err != nil {
- t.Fatal(err)
- }
- webUIInstance = ui
+ ui, err := web.New()
+ if err != nil {
+ t.Fatal(err)
}
cfg := Config{MaxTextBytes: 1024 * 1024}
- ss := newTestSettingsStore(t, cfg)
+ ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
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 {
@@ -150,7 +151,7 @@ func TestRateLimitUnlock(t *testing.T) {
// TestHighlightCode basic expectations.
func TestHighlightCode(t *testing.T) {
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(`func`)) {
t.Fatalf("no keyword span: %s", out)
}
@@ -161,12 +162,12 @@ func TestHighlightCode(t *testing.T) {
t.Fatalf("no string span: %s", out)
}
// unsupported language returns escaped plain text
- plain := highlightCode("x", "text")
+ plain := lang.HighlightCode("x", "text")
if plain != "<b>x</b>" {
t.Fatalf("plain escaping wrong: %q", plain)
}
// 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)
}
}
diff --git a/internal/api/server.go b/internal/api/server.go
new file mode 100644
index 0000000..f9de994
--- /dev/null
+++ b/internal/api/server.go
@@ -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, "
can/%s — palettecan/%s
", can.ID, can.ID)
+ for _, it := range items {
+ fmt.Fprintf(w, `- %s (%s)
`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
+ }
+ fmt.Fprintf(w, "
")
+}
+
+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) }
diff --git a/slugrelease_test.go b/internal/api/slugrelease_test.go
similarity index 75%
rename from slugrelease_test.go
rename to internal/api/slugrelease_test.go
index 1172d78..03e77c3 100644
--- a/slugrelease_test.go
+++ b/internal/api/slugrelease_test.go
@@ -1,18 +1,19 @@
-package main
+package api
import (
+ "palette/internal/store"
+
"testing"
"time"
)
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
// created_at/expires_at, bypassing the API's timestamp handling.
-func insertPasteWithSlug(t *testing.T, s *Store, slug string, createdAt, expiresAt int64) string {
+func insertPasteWithSlug(t *testing.T, s *store.Store, slug string, createdAt, expiresAt int64) string {
t.Helper()
- id := genSlug(6)
- _, err := s.db.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
- VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt)
- if err != nil {
+ id := store.GenSlug(6)
+ 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); err != nil {
t.Fatal(err)
}
return id
@@ -24,14 +25,14 @@ func TestReleaseSlugOnExpiredPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
- if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
+ if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("release-notes"); taken {
t.Fatal("slug should be released after expiry")
}
// slug must be reusable by a new paste
- p, err := s.store.CreatePaste(&Paste{Content: "new", CustomSlug: strPtr("release-notes")})
+ p, err := s.store.CreatePaste(&store.Paste{Content: "new", CustomSlug: strPtr("release-notes")})
if err != nil {
t.Fatalf("reuse slug: %v", err)
}
@@ -45,7 +46,7 @@ func TestReleaseSlugOnOldPaste(t *testing.T) {
now := time.Now().Unix()
// created 31 days ago, no expiry -> released by 30-day reservation rule
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
- if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
+ if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("old-url"); taken {
@@ -58,7 +59,7 @@ func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) {
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
- if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 0 {
+ if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 0 {
t.Fatalf("released %d err %v, want 0", n, err)
}
for _, slug := range []string{"fresh-url", "fresh-url2"} {
@@ -72,7 +73,7 @@ func TestSweeperTickerReleasesSlugs(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
- s.store.StartSweeper(10 * time.Millisecond)
+ s.store.StartSweeper(10*time.Millisecond, store.SlugReservationDays)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
diff --git a/sweep33_test.go b/internal/api/sweep33_test.go
similarity index 92%
rename from sweep33_test.go
rename to internal/api/sweep33_test.go
index cf29b6c..f5afa67 100644
--- a/sweep33_test.go
+++ b/internal/api/sweep33_test.go
@@ -1,4 +1,4 @@
-package main
+package api
import (
"net/http/httptest"
@@ -46,13 +46,6 @@ func TestCreatePasteExpiryBounds(t *testing.T) {
// missing from handlePasteView, so the counter only moved on /raw.
func TestPasteViewIncrementsViewCount(t *testing.T) {
s := testServer(t)
- if webUIInstance == nil {
- ui, err := NewWebUI()
- if err != nil {
- t.Fatal(err)
- }
- webUIInstance = ui
- }
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vc"}`))
@@ -87,13 +80,6 @@ func TestPasteViewIncrementsViewCount(t *testing.T) {
// view for the creator.
func TestJustCreatedViewDoesNotCount(t *testing.T) {
s := testServer(t)
- if webUIInstance == nil {
- ui, err := NewWebUI()
- if err != nil {
- t.Fatal(err)
- }
- webUIInstance = ui
- }
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"jc"}`))
diff --git a/guess.go b/internal/lang/guess.go
similarity index 89%
rename from guess.go
rename to internal/lang/guess.go
index d001070..8f1b11f 100644
--- a/guess.go
+++ b/internal/lang/guess.go
@@ -1,8 +1,7 @@
-package main
+package lang
import (
"encoding/json"
- "net/http"
"regexp"
"strings"
@@ -58,7 +57,8 @@ var canonical = map[string]string{
// paths (empty, JSON, unambiguous markers enry can't see without a filename),
// then enry strategies (shebangs, XML decl, modelines, content heuristics),
// then enry's classifier seeded by our regex hints.
-func guessLang(s string) string {
+// GuessLang detects a language from pasted content.
+func GuessLang(s string) string {
src := strings.TrimSpace(s)
if src == "" {
return ""
@@ -108,19 +108,3 @@ func normalizeLang(lang string) string {
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})
-}
diff --git a/guess_test.go b/internal/lang/guess_test.go
similarity index 88%
rename from guess_test.go
rename to internal/lang/guess_test.go
index b20d853..c142541 100644
--- a/guess_test.go
+++ b/internal/lang/guess_test.go
@@ -1,4 +1,4 @@
-package main
+package lang
import (
"strings"
@@ -23,8 +23,8 @@ func TestGuessLangExisting(t *testing.T) {
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
}
for src, want := range cases {
- if got := guessLang(src); got != want {
- t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
+ if got := GuessLang(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",
}
for src, want := range cases {
- if got := guessLang(src); got != want {
- t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
+ if got := GuessLang(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",
}
for src, want := range cases {
- if got := guessLang(src); got != want {
- t.Errorf("guessLang(%q) = %q, want %q", src, got, want)
+ if got := GuessLang(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
// our stored ids.
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)
}
- 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)
}
// uncurated languages still come back lowercase
- if got := guessLang("{{.Name}}
\n"); got != strings.ToLower(got) {
+ if got := GuessLang("{{.Name}}
\n"); got != strings.ToLower(got) {
t.Errorf("expected lowercase output, got %q", got)
}
}
diff --git a/highlight.go b/internal/lang/highlight.go
similarity index 97%
rename from highlight.go
rename to internal/lang/highlight.go
index ce28cf2..1aaad90 100644
--- a/highlight.go
+++ b/internal/lang/highlight.go
@@ -1,4 +1,4 @@
-package main
+package lang
import (
"html"
@@ -135,8 +135,8 @@ func containsStr(list []string, s string) bool {
// highlightCode returns HTML with highlighting spans; safe because all
// non-token text is html-escaped.
-func highlightCode(content, lang string) string {
- l, h, ok := resolveLang(lang)
+func HighlightCode(content, langID string) string {
+ l, h, ok := resolveLang(langID)
_ = l
if !ok {
return html.EscapeString(content)
@@ -144,7 +144,7 @@ func highlightCode(content, lang string) string {
lines := strings.Split(content, "\n")
out := make([]string, len(lines))
for i, line := range lines {
- out[i] = highlightLine(line, h, lang)
+ out[i] = highlightLine(line, h, langID)
}
return strings.Join(out, "\n")
}
diff --git a/internal/store/burn.go b/internal/store/burn.go
new file mode 100644
index 0000000..bb7f377
--- /dev/null
+++ b/internal/store/burn.go
@@ -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
+}
diff --git a/customslug.go b/internal/store/customslug.go
similarity index 77%
rename from customslug.go
rename to internal/store/customslug.go
index dbcfbbc..75013f6 100644
--- a/customslug.go
+++ b/internal/store/customslug.go
@@ -1,8 +1,7 @@
-package main
+package store
import (
"errors"
- "fmt"
"regexp"
"strings"
)
@@ -16,16 +15,16 @@ var reservedSlugs = map[string]bool{
"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 errReservedSlug = errors.New("that slug is reserved")
-var errSlugTaken = errors.New("that slug is already taken")
+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 ErrSlugTaken = errors.New("that slug is already taken")
func ValidateCustomSlug(slug string) error {
if !slugRE.MatchString(slug) {
- return errInvalidSlug
+ return ErrInvalidSlug
}
if reservedSlugs[strings.ToLower(slug)] {
- return errReservedSlug
+ return ErrReservedSlug
}
return nil
}
@@ -45,5 +44,3 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
}
return n > 0, nil
}
-
-var _ = fmt.Sprintf // keep fmt if unused later
diff --git a/password.go b/internal/store/password.go
similarity index 81%
rename from password.go
rename to internal/store/password.go
index 0907425..8481717 100644
--- a/password.go
+++ b/internal/store/password.go
@@ -1,4 +1,4 @@
-package main
+package store
import (
"crypto/rand"
@@ -19,7 +19,8 @@ const (
argonSaltLen = 16
)
-func argon2idHash(pw string) (string, error) {
+// Argon2IDHash hashes a password with argon2id.
+func Argon2IDHash(pw string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
@@ -27,11 +28,10 @@ func argon2idHash(pw string) (string, error) {
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads,
- base64.RawStdEncoding.EncodeToString(salt),
- base64.RawStdEncoding.EncodeToString(key)), nil
+ base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
-func checkPassword(hash, pw string) bool {
+func CheckPassword(hash, pw string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..a323038
--- /dev/null
+++ b/internal/store/store.go
@@ -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
+}
diff --git a/web/static/app.css b/internal/web/static/app.css
similarity index 100%
rename from web/static/app.css
rename to internal/web/static/app.css
diff --git a/web/static/table.js b/internal/web/static/table.js
similarity index 100%
rename from web/static/table.js
rename to internal/web/static/table.js
diff --git a/web/templates/.keep b/internal/web/templates/.keep
similarity index 100%
rename from web/templates/.keep
rename to internal/web/templates/.keep
diff --git a/web/templates/admin.html b/internal/web/templates/admin.html
similarity index 100%
rename from web/templates/admin.html
rename to internal/web/templates/admin.html
diff --git a/web/templates/foot.html b/internal/web/templates/foot.html
similarity index 100%
rename from web/templates/foot.html
rename to internal/web/templates/foot.html
diff --git a/web/templates/history.html b/internal/web/templates/history.html
similarity index 100%
rename from web/templates/history.html
rename to internal/web/templates/history.html
diff --git a/web/templates/layout.html b/internal/web/templates/layout.html
similarity index 100%
rename from web/templates/layout.html
rename to internal/web/templates/layout.html
diff --git a/web/templates/mine.html b/internal/web/templates/mine.html
similarity index 100%
rename from web/templates/mine.html
rename to internal/web/templates/mine.html
diff --git a/web/templates/new.html b/internal/web/templates/new.html
similarity index 100%
rename from web/templates/new.html
rename to internal/web/templates/new.html
diff --git a/web/templates/paste.html b/internal/web/templates/paste.html
similarity index 100%
rename from web/templates/paste.html
rename to internal/web/templates/paste.html
diff --git a/web/templates/settings.html b/internal/web/templates/settings.html
similarity index 100%
rename from web/templates/settings.html
rename to internal/web/templates/settings.html
diff --git a/web/templates/unlock.html b/internal/web/templates/unlock.html
similarity index 100%
rename from web/templates/unlock.html
rename to internal/web/templates/unlock.html
diff --git a/web.go b/internal/web/web.go
similarity index 60%
rename from web.go
rename to internal/web/web.go
index 3689590..59a33e0 100644
--- a/web.go
+++ b/internal/web/web.go
@@ -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 (
"crypto/hmac"
@@ -12,32 +14,32 @@ import (
"log"
"net/http"
"os"
- "strconv"
"strings"
"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
-//go:embed web/static
+//go:embed static
var staticFS embed.FS
-type webUI struct {
+type UI struct {
tmpl *template.Template
}
-func NewWebUI() (*webUI, error) {
+func New() (*UI, error) {
funcs := template.FuncMap{
"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 {
return nil, err
}
- return &webUI{tmpl: t}, nil
+ return &UI{tmpl: t}, nil
}
func humanSize(n int) string {
@@ -50,20 +52,18 @@ func humanSize(n int) string {
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
-func staticHandler() http.Handler {
- sub, _ := fs.Sub(staticFS, "web/static")
+func (u *UI) StaticHandler() http.Handler {
+ sub, _ := fs.Sub(staticFS, "static")
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")
- 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)
}
}
-var webUIInstance *webUI
-
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
// (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_ cookie by
@@ -87,22 +87,6 @@ func unlockToken(id string) string {
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 {
s := time.Now().Unix() - ts
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
gutter := ""
for i := 1; i <= lines; i++ {
@@ -154,7 +162,7 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
"HasPassword": row.PasswordHash.Valid,
"BurnAfterRead": row.BurnAfterRead,
"CustomSlug": row.CustomSlug.String,
- "ContentHTML": template.HTML(highlightCode(row.Content, row.Language.String)), // safe: highlightCode escapes all non-span text
+ "ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text
"ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines,
@@ -167,18 +175,18 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
"ExpiresIn": expIn,
"DeletionToken": deletionToken,
"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),
"JustCreated": justCreated,
"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.
-func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- row, err := a.store.GetPaste(id)
+// HandlePasteView renders the paste view; supports both ID and custom slug.
+func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ row, err := h.Store.GetPaste(id)
if err != nil {
http.Error(w, "db error", 500)
return
@@ -194,13 +202,13 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
if row.PasswordHash.Valid {
// if a password was submitted via unlock form, verify and set cookie for this paste
if r.Method == http.MethodPost {
- if !rateLimitUnlock(row.ID, r) {
- writeRateLimited(w, 60)
+ if !h.rateLimitUnlock(row.ID, r) {
+ h.writeRateLimited(w, 60)
return
}
r.ParseForm()
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
// unforgable. A static value ("1") let anyone bypass the password
// by setting pw_=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
}
}
- a.renderPaste(w, row, false, "", nil)
+ h.renderPaste(w, row, false, "", nil)
return
}
- renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
+ h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
return
}
// check cookie — must carry the valid per-paste unlock token (#34)
c, err := r.Cookie("pw_" + 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
}
}
@@ -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).
// The just-created banner render does not count as a view.
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.
if !justCreated {
- rem, _ := a.store.registerRead(row, currentViewerID(r))
- a.renderPaste(w, row, false, "", rem)
+ rem, _ := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
+ h.renderPaste(w, row, false, "", rem)
return
}
// 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} }
diff --git a/main.go b/main.go
deleted file mode 100644
index 9fa5c21..0000000
--- a/main.go
+++ /dev/null
@@ -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, "can/%s — palettecan/%s
", can.ID, can.ID)
- for _, it := range items {
- fmt.Fprintf(w, `- %s (%s)
`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
- }
- fmt.Fprintf(w, "
")
-}
-
-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, "%s — palette%s
",
- 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
-}