diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6b2bf74 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module palette + +go 1.27.1 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + golang.org/x/crypto v0.39.0 + modernc.org/sqlite v1.58.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..62c8c10 --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8b18889 --- /dev/null +++ b/main.go @@ -0,0 +1,509 @@ +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 + } + + 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" + } + + _, err := s.db.Exec(`INSERT INTO pastes + (id, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + id, 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) + }) + + // 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 + } + if p.CustomSlug != nil && *p.CustomSlug != "" { + writeErr(w, 400, "custom slugs not implemented yet") + 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) handleHome(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("palette pastebin api\nPOST /api/pastes {\"content\": \"...\", \"language\": \"go\", \"expires_in\": \"168h\", \"password\": \"...\", \"visibility\": \"public\"}\nGET /api/pastes/{id}\nGET /api/public?limit=25&offset=0\nGET /raw/{id}\n")) +} + +func (a *apiServer) handlePastePage(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + // if it looks like an asset request, 404 + if strings.Contains(id, ".") { + http.NotFound(w, r) + return + } + row, err := a.store.GetPaste(id) + if err != nil || row == nil { + http.NotFound(w, r) + return + } + a.store.IncrementViews(row.ID) + // render basic view; full templates come later with frontend work + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, "
%s", + 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 +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c06f0bb --- /dev/null +++ b/main_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func testServer(t *testing.T) *apiServer { + t.Helper() + store, err := OpenStore(":memory:") + if err != nil { + t.Fatal(err) + } + return &apiServer{store: store, cfg: Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}} +} + +func TestCreateAndGetPaste(t *testing.T) { + s := testServer(t) + h := s.routes() + + // create + body := `{"content":"hello world","language":"txt"}` + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String()) + } + var created struct{ ID string `json:"id"` } + json.Unmarshal(rec.Body.Bytes(), &created) + if len(created.ID) != 6 { + t.Fatalf("unexpected id: %q", created.ID) + } + + // get + req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("get: got %d", rec.Code) + } + var got map[string]any + json.Unmarshal(rec.Body.Bytes(), &got) + if got["content"] != "hello world" { + t.Fatalf("content mismatch: %v", got["content"]) + } + if got["title"] != nil { + t.Fatalf("title should be null, got %v", got["title"]) + } +} + +func TestPasswordProtection(t *testing.T) { + s := testServer(t) + h := s.routes() + + body := `{"content":"secret","password":"hunter2"}` + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var created struct{ ID string `json:"id"` } + json.Unmarshal(rec.Body.Bytes(), &created) + + // without password -> 401 + req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 401 { + t.Fatalf("expected 401, got %d", rec.Code) + } + + // with password -> 200 + req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=hunter2", nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("expected 200 with password, got %d", rec.Code) + } + + // wrong password -> 401 + req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=nope", nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 401 { + t.Fatalf("expected 401 wrong pw, got %d", rec.Code) + } +} + +func TestExpiryValidation(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x","expires_in":"notaduration"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 400 { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestSizeLimit(t *testing.T) { + s := testServer(t) + h := s.routes() + + big := strings.Repeat("a", int(s.cfg.MaxTextBytes)+1) + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"`+big+`"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 413 { + t.Fatalf("expected 413, got %d", rec.Code) + } +} + +func TestSoftDelete(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var created struct{ ID string `json:"id"` } + json.Unmarshal(rec.Body.Bytes(), &created) + + req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("delete: got %d", rec.Code) + } + + req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 404 { + t.Fatalf("expected 404 after delete, got %d", rec.Code) + } +} + +func TestListPublicExcludesUnlisted(t *testing.T) { + s := testServer(t) + h := s.routes() + + for _, vis := range []string{"public", "unlisted"} { + body := `{"content":"x","visibility":"` + vis + `"}` + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + } + + req := httptest.NewRequest("GET", "/api/public", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var resp struct { + Total int `json:"total"` + Items []map[string]any `json:"items"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Total != 1 { + t.Fatalf("expected 1 public paste, got %d", resp.Total) + } +} + +func TestSweepSoftDeletesAfterGrace(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var created struct{ ID string `json:"id"` } + json.Unmarshal(rec.Body.Bytes(), &created) + + s.store.SoftDelete(created.ID) + + // simulate grace elapsed + past := time.Now().Unix() - (softDeleteGraceDays+1)*86400 + s.store.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID) + + s.store.SweepExpired() + + var count int + s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&count) + if count != 0 { + t.Fatal("expected hard delete after grace period") + } +} + +func TestSlugCharset(t *testing.T) { + for i := 0; i < 100; i++ { + s := genSlug(6) + for _, c := range s { + if !strings.ContainsRune(slugAlphabet, c) { + t.Fatalf("bad char %q in slug %q", c, s) + } + } + } +} + +func TestRawEndpoint(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var created struct{ ID string `json:"id"` } + json.Unmarshal(rec.Body.Bytes(), &created) + + req = httptest.NewRequest("GET", "/raw/"+created.ID, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("raw: got %d", rec.Code) + } + if rec.Body.String() != "raw content here" { + t.Fatalf("raw body mismatch: %q", rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/plain" { + t.Fatalf("raw content-type: %q", ct) + } +} + +func TestNotFound(t *testing.T) { + s := testServer(t) + h := s.routes() + req := httptest.NewRequest("GET", "/api/pastes/zzzzzz", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} diff --git a/palette b/palette new file mode 100755 index 0000000..c599cef Binary files /dev/null and b/palette differ diff --git a/palette.db b/palette.db new file mode 100644 index 0000000..cc04c10 Binary files /dev/null and b/palette.db differ diff --git a/palette.db-shm b/palette.db-shm new file mode 100644 index 0000000..b9d03e4 Binary files /dev/null and b/palette.db-shm differ diff --git a/palette.db-wal b/palette.db-wal new file mode 100644 index 0000000..710dffe Binary files /dev/null and b/palette.db-wal differ diff --git a/password.go b/password.go new file mode 100644 index 0000000..0907425 --- /dev/null +++ b/password.go @@ -0,0 +1,52 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// argon2id with sane defaults +const ( + argonTime = 1 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 + argonSaltLen = 16 +) + +func argon2idHash(pw string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)), nil +} + +func checkPassword(hash, pw string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m, t uint32 + var p uint8 + fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p) + salt, err1 := base64.RawStdEncoding.DecodeString(parts[4]) + want, err2 := base64.RawStdEncoding.DecodeString(parts[5]) + if err1 != nil || err2 != nil { + return false + } + got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// cryptoRead is used by genSlug +func cryptoRead(b []byte) (int, error) { return rand.Read(b) } diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..76a8d06 --- /dev/null +++ b/web/static/app.css @@ -0,0 +1 @@ +/* palette static assets placeholder */ diff --git a/web/templates/.keep b/web/templates/.keep new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/web/templates/.keep @@ -0,0 +1 @@ +placeholder