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"` } 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 } 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 ); 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 ); `) 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 } _, 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) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now) 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 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) 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 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); 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.Get("/public", a.handleListPublic) 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 (minimal for now) r.Get("/", a.handleHome) r.Get("/{id}", a.handlePastePage) 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, "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 } 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, }) } 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) 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 }