794 lines
24 KiB
Go
794 lines
24 KiB
Go
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
func (a *apiServer) routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.Timeout(30 * time.Second))
|
|
r.Use(viewerCookieMiddleware)
|
|
|
|
// 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.cfg.MaxTextBytes {
|
|
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes))
|
|
return
|
|
}
|
|
p.ViewerID = currentViewerID(r)
|
|
created, err := a.store.CreatePaste(&p)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 201, map[string]any{
|
|
"id": created.ID,
|
|
"deletion_token": created.DeletionToken,
|
|
"url": "/" + created.ID,
|
|
"raw_url": "/raw/" + created.ID,
|
|
"api_url": "/api/pastes/" + created.ID,
|
|
"expires_at": created.ExpiresAt,
|
|
"created_at": created.CreatedAt,
|
|
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
|
|
})
|
|
}
|
|
|
|
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
if row == nil {
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
writeErr(w, 404, "paste expired")
|
|
return
|
|
}
|
|
if row.burned() { // #49: read budget exhausted
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
if row.PasswordHash.Valid {
|
|
// require password via header or query
|
|
pw := r.Header.Get("X-Paste-Password")
|
|
if pw == "" {
|
|
pw = r.URL.Query().Get("password")
|
|
}
|
|
if pw == "" || !checkPassword(row.PasswordHash.String, pw) {
|
|
writeErr(w, 401, "password required")
|
|
return
|
|
}
|
|
}
|
|
nullPtr := func(ns sql.NullString) *string {
|
|
if ns.Valid {
|
|
return &ns.String
|
|
}
|
|
return nil
|
|
}
|
|
rem, _ := a.store.registerRead(row, currentViewerID(r)) // #49 (also covers legacy burn)
|
|
writeJSON(w, 200, map[string]any{
|
|
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
|
"language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt,
|
|
"view_count": row.ViewCount, "visibility": row.Visibility,
|
|
"reads_remaining": rem,
|
|
})
|
|
}
|
|
|
|
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil || row == nil {
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
// viewer-cookie delete enforcement (#37): only the browser that created
|
|
// the paste (matching vwr) may delete it via this endpoint. Requests with
|
|
// no client-sent vwr cookie (plain API clients) are unaffected.
|
|
vid := currentViewerID(r)
|
|
if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid {
|
|
writeErr(w, 403, "not your paste")
|
|
return
|
|
}
|
|
if err := a.store.SoftDelete(row.ID); err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
|
}
|
|
|
|
// handleListMine serves /api/mine: pastes created from this browser (#37).
|
|
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
|
vid := currentViewerID(r)
|
|
if vid == "" {
|
|
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
rows, total, err := a.store.ListMine(vid, limit, offset)
|
|
if err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title)
|
|
items = append(items, map[string]any{
|
|
"id": row.ID, "title": title, "language": lang,
|
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
|
"custom_slug": nullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
|
})
|
|
}
|
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
|
}
|
|
|
|
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 25
|
|
}
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
rows, total, err := a.store.ListPublic(limit, offset)
|
|
if err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title)
|
|
items = append(items, map[string]any{
|
|
"id": row.ID, "title": title, "language": lang,
|
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
|
"custom_slug": nullStrPtr(row.CustomSlug),
|
|
})
|
|
}
|
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
|
}
|
|
|
|
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil || row == nil {
|
|
http.Error(w, "not found", 404)
|
|
return
|
|
}
|
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
http.Error(w, "paste expired", 404)
|
|
return
|
|
}
|
|
if row.PasswordHash.Valid {
|
|
http.Error(w, "password required", 401)
|
|
return
|
|
}
|
|
if row.burned() { // #49: read budget exhausted
|
|
http.Error(w, "not found", 404)
|
|
return
|
|
}
|
|
// #49 decision: raw reads count against the read budget too, with the
|
|
// same per-viewer 15-minute dedupe window as page views.
|
|
a.store.registerRead(row, currentViewerID(r))
|
|
// #34: content_type is attacker-controlled via the create API. Serving it
|
|
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
|
// render as active content on this origin when fetched from /raw —
|
|
// stored XSS. Only pass through a fixed safe set; anything else is
|
|
// served as plain text with nosniff.
|
|
ct := row.ContentType
|
|
if !safeRawContentType(ct) {
|
|
ct = "text/plain; charset=utf-8"
|
|
}
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
a.store.IncrementViews(row.ID)
|
|
w.Write([]byte(row.Content))
|
|
}
|
|
|
|
// safeRawContentType reports whether ct is in the fixed set of types that are
|
|
// safe to serve verbatim on /raw (no active-content execution contexts).
|
|
func safeRawContentType(ct string) bool {
|
|
base := ct
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
base = ct[:i]
|
|
}
|
|
base = strings.ToLower(strings.TrimSpace(base))
|
|
switch base {
|
|
case "text/plain", "text/markdown", "text/x-markdown",
|
|
"application/json", "application/pdf",
|
|
"image/png", "image/jpeg", "image/gif", "image/webp",
|
|
"application/octet-stream":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
can, err := a.store.GetCan(id)
|
|
if err != nil || can == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
items, _ := a.store.ListCanItems(can.ID)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
|
|
for _, it := range items {
|
|
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
|
|
}
|
|
fmt.Fprintf(w, "</ul></body></html>")
|
|
}
|
|
|
|
func nullStrOr(ns sql.NullString, def string) string {
|
|
if ns.Valid {
|
|
return ns.String
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (a *apiServer) handleHome(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte("palette pastebin api\nPOST /api/pastes {\"content\": \"...\", \"language\": \"go\", \"expires_in\": \"168h\", \"password\": \"...\", \"visibility\": \"public\"}\nGET /api/pastes/{id}\nGET /api/public?limit=25&offset=0\nGET /raw/{id}\n"))
|
|
}
|
|
|
|
func (a *apiServer) handlePastePage(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
// if it looks like an asset request, 404
|
|
if strings.Contains(id, ".") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil || row == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
a.store.IncrementViews(row.ID)
|
|
// render basic view; full templates come later with frontend work
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, "<!doctype html><html><head><title>%s — palette</title></head><body><pre>%s</pre></body></html>",
|
|
row.ID, templateEsc(row.Content))
|
|
}
|
|
|
|
func templateEsc(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func main() {
|
|
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)
|
|
|
|
ui, err := NewWebUI()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
webUIInstance = ui
|
|
srv := &apiServer{store: store, cfg: cfg}
|
|
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
|
|
}
|