package main import ( "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 ) 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"` 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"` 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 Visibility string CanID sql.NullString CreatedAt int64 DeletedAt sql.NullInt64 ViewCount int Size int DeletionToken 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 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 } } 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) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken) 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 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) 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 } 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) } func (s *Store) StartSweeper(every time.Duration) { go func() { t := time.NewTicker(every) for range t.C { s.SweepExpired() } }() } 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)) // 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.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.Handle("/static/*", staticHandler()) r.Get("/unlock/{id}", a.handlePasteView) r.Post("/unlock/{id}", a.handlePasteView) r.Get("/{id}", a.handlePasteView) r.NotFound(func(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "not found") }) return r } func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { 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 } 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, }) } 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.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 } a.store.maybeBurn(row) 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, }) } 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 } if err := a.store.SoftDelete(row.ID); err != nil { writeErr(w, 500, "db error") return } writeJSON(w, 200, map[string]string{"status": "soft-deleted"}) } 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, }) } 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 } w.Header().Set("Content-Type", row.ContentType) a.store.IncrementViews(row.ID) w.Write([]byte(row.Content)) } 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, "
%s", 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 }