Phase 1 API: pastes CRUD, password lock (argon2id), expiry sweeper, soft delete with 7d grace, public list
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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, "<!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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+235
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
+52
@@ -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) }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* palette static assets placeholder */
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
placeholder
|
||||||
Reference in New Issue
Block a user