From 4f1e901f0486079fb004c5d013acb7a8def4193c Mon Sep 17 00:00:00 2001 From: poslop Date: Wed, 9 Sep 2026 01:33:39 -0500 Subject: [PATCH] Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35) --- .gitignore | 2 + Dockerfile | 2 +- burn.go | 114 --- admin.go => internal/api/admin.go | 35 +- admin_test.go => internal/api/admin_test.go | 18 +- internal/api/burn.go | 43 + burn_test.go => internal/api/burn_test.go | 4 +- .../api/burnreads_test.go | 19 +- cans.go => internal/api/cans.go | 63 +- cans_test.go => internal/api/cans_test.go | 2 +- .../api/customslug_test.go | 6 +- internal/api/guess.go | 26 + main_test.go => internal/api/main_test.go | 30 +- mine_test.go => internal/api/mine_test.go | 14 +- .../api/pentest_cookie_test.go | 2 +- .../api/pentest_helpers_test.go | 2 +- .../api/pentest_rawct_test.go | 2 +- ratelimit.go => internal/api/ratelimit.go | 5 +- .../api/ratelimit_test.go | 27 +- internal/api/server.go | 421 +++++++++ .../api/slugrelease_test.go | 23 +- .../api/sweep33_test.go | 16 +- guess.go => internal/lang/guess.go | 22 +- guess_test.go => internal/lang/guess_test.go | 20 +- highlight.go => internal/lang/highlight.go | 8 +- internal/store/burn.go | 71 ++ customslug.go => internal/store/customslug.go | 15 +- password.go => internal/store/password.go | 10 +- internal/store/store.go | 463 ++++++++++ {web => internal/web}/static/app.css | 0 {web => internal/web}/static/table.js | 0 {web => internal/web}/templates/.keep | 0 {web => internal/web}/templates/admin.html | 0 {web => internal/web}/templates/foot.html | 0 {web => internal/web}/templates/history.html | 0 {web => internal/web}/templates/layout.html | 0 {web => internal/web}/templates/mine.html | 0 {web => internal/web}/templates/new.html | 0 {web => internal/web}/templates/paste.html | 0 {web => internal/web}/templates/settings.html | 0 {web => internal/web}/templates/unlock.html | 0 web.go => internal/web/web.go | 136 +-- main.go | 833 ------------------ 43 files changed, 1251 insertions(+), 1203 deletions(-) delete mode 100644 burn.go rename admin.go => internal/api/admin.go (85%) rename admin_test.go => internal/api/admin_test.go (93%) create mode 100644 internal/api/burn.go rename burn_test.go => internal/api/burn_test.go (96%) rename burnreads_test.go => internal/api/burnreads_test.go (93%) rename cans.go => internal/api/cans.go (69%) rename cans_test.go => internal/api/cans_test.go (99%) rename customslug_test.go => internal/api/customslug_test.go (95%) create mode 100644 internal/api/guess.go rename main_test.go => internal/api/main_test.go (91%) rename mine_test.go => internal/api/mine_test.go (92%) rename pentest_cookie_test.go => internal/api/pentest_cookie_test.go (98%) rename pentest_helpers_test.go => internal/api/pentest_helpers_test.go (95%) rename pentest_rawct_test.go => internal/api/pentest_rawct_test.go (99%) rename ratelimit.go => internal/api/ratelimit.go (96%) rename ratelimit_test.go => internal/api/ratelimit_test.go (93%) create mode 100644 internal/api/server.go rename slugrelease_test.go => internal/api/slugrelease_test.go (75%) rename sweep33_test.go => internal/api/sweep33_test.go (92%) rename guess.go => internal/lang/guess.go (89%) rename guess_test.go => internal/lang/guess_test.go (88%) rename highlight.go => internal/lang/highlight.go (97%) create mode 100644 internal/store/burn.go rename customslug.go => internal/store/customslug.go (77%) rename password.go => internal/store/password.go (81%) create mode 100644 internal/store/store.go rename {web => internal/web}/static/app.css (100%) rename {web => internal/web}/static/table.js (100%) rename {web => internal/web}/templates/.keep (100%) rename {web => internal/web}/templates/admin.html (100%) rename {web => internal/web}/templates/foot.html (100%) rename {web => internal/web}/templates/history.html (100%) rename {web => internal/web}/templates/layout.html (100%) rename {web => internal/web}/templates/mine.html (100%) rename {web => internal/web}/templates/new.html (100%) rename {web => internal/web}/templates/paste.html (100%) rename {web => internal/web}/templates/settings.html (100%) rename {web => internal/web}/templates/unlock.html (100%) rename web.go => internal/web/web.go (60%) delete mode 100644 main.go 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 — palette

can/%s

") +} + +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 — palette

can/%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 -}