Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
CI / test (push) Successful in 19s
CI / docker (push) Failing after 2m7s

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