Files
bluebird/internal/app/app.go
T

472 lines
16 KiB
Go

package app
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"filippo.io/age"
"golang.org/x/sys/unix"
_ "modernc.org/sqlite"
)
type App struct {
requestWG sync.WaitGroup
cfg Config
db *sql.DB
root *os.Root
lock *os.File
secret []byte
identity *age.X25519Identity
mu sync.Mutex
passwordSlots chan struct{}
rateMu sync.Mutex
rates map[string]*rateEntry
transferMu sync.Mutex
transfers map[string]int
activeFiles map[string]bool
activeObjects map[string]int
extraDisk int64
proxies []*net.IPNet
now func() time.Time
log *slog.Logger
}
const schema = `
CREATE TABLE IF NOT EXISTS schema_version(version INTEGER NOT NULL);
INSERT INTO schema_version SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM schema_version);
CREATE TABLE IF NOT EXISTS settings(id INTEGER PRIMARY KEY CHECK(id=1),json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS runtime_settings(id INTEGER PRIMARY KEY CHECK(id=1),json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS admins(id INTEGER PRIMARY KEY CHECK(id=1),username TEXT NOT NULL,password_hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS admin_sessions(token_hash TEXT PRIMARY KEY,expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS visitors(id TEXT PRIMARY KEY,token_hash TEXT UNIQUE NOT NULL,expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS parcels(id TEXT PRIMARY KEY,visitor TEXT NOT NULL,ip TEXT NOT NULL,state TEXT NOT NULL,created INTEGER NOT NULL,expires INTEGER NOT NULL DEFAULT 0,hard_expires INTEGER NOT NULL DEFAULT 0,code_hash TEXT UNIQUE,password_hash TEXT NOT NULL,manage_hash TEXT NOT NULL,version INTEGER NOT NULL DEFAULT 1,days INTEGER NOT NULL,downloads INTEGER NOT NULL,renewal_days INTEGER NOT NULL,renewal_mode TEXT NOT NULL,lifetime_days INTEGER NOT NULL,upload_expires INTEGER NOT NULL,usage_day TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS objects(id TEXT PRIMARY KEY,digest TEXT UNIQUE NOT NULL,size INTEGER NOT NULL,stored_bytes INTEGER NOT NULL,encrypted INTEGER NOT NULL,key_id TEXT NOT NULL,created INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS files(id TEXT PRIMARY KEY,parcel TEXT NOT NULL REFERENCES parcels(id) ON DELETE CASCADE,name TEXT NOT NULL,size INTEGER NOT NULL,status TEXT NOT NULL,object_id TEXT REFERENCES objects(id),reserved INTEGER NOT NULL,remaining INTEGER NOT NULL,received INTEGER NOT NULL DEFAULT 0,charged INTEGER NOT NULL DEFAULT 0,attempts INTEGER NOT NULL DEFAULT 0,expires INTEGER NOT NULL DEFAULT 0,hard_expires INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS grants(token_hash TEXT PRIMARY KEY,parcel TEXT NOT NULL REFERENCES parcels(id) ON DELETE CASCADE,version INTEGER NOT NULL,expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS usage(subject TEXT NOT NULL,day TEXT NOT NULL,bytes INTEGER NOT NULL,uploads INTEGER NOT NULL,PRIMARY KEY(subject,day));
CREATE TABLE IF NOT EXISTS downloads(id TEXT PRIMARY KEY,file_id TEXT NOT NULL,created INTEGER NOT NULL,started INTEGER NOT NULL DEFAULT 0,completed INTEGER NOT NULL DEFAULT 0,refunded INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY AUTOINCREMENT,created INTEGER NOT NULL,action TEXT NOT NULL,target TEXT NOT NULL,source TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS audit_created ON audit(created);
CREATE INDEX IF NOT EXISTS audit_action_id ON audit(action,id DESC);
CREATE INDEX IF NOT EXISTS parcels_created_id ON parcels(created DESC,id DESC);
CREATE INDEX IF NOT EXISTS files_parcel ON files(parcel);
CREATE INDEX IF NOT EXISTS files_object ON files(object_id);
CREATE INDEX IF NOT EXISTS parcels_expiry ON parcels(state,expires);
CREATE INDEX IF NOT EXISTS parcels_visitor ON parcels(visitor);
`
func Open(c Config) (*App, error) { return open(c, false) }
func OpenForRestore(c Config) (*App, error) { return open(c, true) }
func open(c Config, restoring bool) (*App, error) {
if e := c.Validate(); e != nil {
return nil, e
}
if e := os.MkdirAll(c.DataDir, 0700); e != nil {
return nil, e
}
root, e := os.OpenRoot(c.DataDir)
if e != nil {
return nil, e
}
if !restoring {
if _, me := root.Stat("restore.incomplete"); me == nil {
root.Close()
return nil, errors.New("恢复未完成,拒绝启动;请检查恢复错误并重新恢复到空目录")
}
}
a := &App{cfg: c, root: root, now: time.Now, log: slog.Default(), passwordSlots: make(chan struct{}, 2), rates: map[string]*rateEntry{}, transfers: map[string]int{}, activeFiles: map[string]bool{}, activeObjects: map[string]int{}}
fail := func(e error) (*App, error) { a.Close(); return nil, e }
for _, d := range []string{"db", "objects", "tmp", "secrets", "quarantine"} {
if e = root.Mkdir(d, 0700); e != nil && !errors.Is(e, os.ErrExist) {
return fail(e)
}
st, se := root.Lstat(d)
if se != nil {
return fail(se)
}
if !st.IsDir() || st.Mode()&os.ModeSymlink != 0 {
return fail(errors.New("数据子目录必须为真实目录"))
}
}
a.lock, e = root.OpenFile("service.lock", os.O_CREATE|os.O_RDWR, 0600)
if e != nil {
return fail(e)
}
if e = unix.Flock(int(a.lock.Fd()), unix.LOCK_EX|unix.LOCK_NB); e != nil {
return fail(errors.New("数据目录已被另一进程使用"))
}
secretPath := "secrets/hmac.key"
if _, de := root.Stat("db/bluebird.db"); de == nil {
if _, se := root.Lstat(secretPath); se != nil {
return fail(errors.New("既有数据库缺少 HMAC 密钥"))
}
}
a.secret, e = readOrCreate(root, secretPath, func() ([]byte, error) { return []byte(randomToken()), nil })
if e != nil {
return fail(e)
}
if len(a.secret) < 32 {
return fail(errors.New("HMAC 密钥损坏"))
}
if st, se := root.Lstat("secrets/age.key"); se == nil && !st.Mode().IsRegular() {
return fail(errors.New("加密私钥必须为普通文件"))
}
key, e := root.ReadFile("secrets/age.key")
if e == nil {
a.identity, e = age.ParseX25519Identity(strings.TrimSpace(string(key)))
if e != nil {
return fail(e)
}
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
for _, path := range []string{"db/bluebird.db", "db/bluebird.db-wal", "db/bluebird.db-shm", "service.lock"} {
if st, se := root.Lstat(path); se == nil && !st.Mode().IsRegular() {
return fail(errors.New("数据库和锁文件必须为普通文件"))
}
}
dbPath := filepath.Join(c.DataDir, "db", "bluebird.db")
if st, se := root.Lstat("db/bluebird.db"); se == nil && st.Mode()&os.ModeSymlink != 0 {
return fail(errors.New("禁止数据库符号链接"))
}
a.db, e = sql.Open("sqlite", dbPath)
if e != nil {
return fail(e)
}
a.db.SetMaxOpenConns(1)
if _, e = a.db.Exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; PRAGMA temp_store=MEMORY; " + schema); e != nil {
return fail(e)
}
var version int
if e = a.db.QueryRow("SELECT version FROM schema_version").Scan(&version); e != nil || version != 1 {
return fail(errors.New("不支持的数据库版本"))
}
_ = root.Chmod("db/bluebird.db", 0600)
if a.identity == nil {
var n int
if e = a.db.QueryRow("SELECT count(*) FROM objects WHERE encrypted=1").Scan(&n); e != nil {
return fail(e)
}
if n > 0 {
return fail(errors.New("加密私钥丢失,拒绝生成替代密钥"))
}
if c.Encryption {
b, e := readOrCreate(root, "secrets/age.key", func() ([]byte, error) {
key, e := age.GenerateX25519Identity()
if e != nil {
return nil, e
}
return []byte(key.String() + "\n"), nil
})
if e != nil {
return fail(e)
}
a.identity, e = age.ParseX25519Identity(strings.TrimSpace(string(b)))
if e != nil {
return fail(e)
}
}
}
b, e := json.Marshal(c.Policy)
if e != nil {
return fail(e)
}
if _, e = a.db.Exec("INSERT OR IGNORE INTO settings VALUES(1,?)", string(b)); e != nil {
return fail(e)
}
if _, e = a.policy(); e != nil {
return fail(e)
}
b, e = json.Marshal(c.InitialRuntimeSettings())
if e != nil {
return fail(e)
}
if _, e = a.db.Exec("INSERT OR IGNORE INTO runtime_settings VALUES(1,?)", string(b)); e != nil {
return fail(e)
}
if _, e = a.runtimeSettings(); e != nil {
return fail(e)
}
if e = a.initializeAuditUsage(); e != nil {
return fail(e)
}
for _, p := range c.TrustedProxies {
_, n, _ := net.ParseCIDR(p)
a.proxies = append(a.proxies, n)
}
if !restoring {
if e = a.recover(); e != nil {
return fail(e)
}
}
return a, nil
}
func readOrCreate(root *os.Root, path string, generate func() ([]byte, error)) ([]byte, error) {
if st, e := root.Lstat(path); e == nil && !st.Mode().IsRegular() {
return nil, errors.New("密钥必须为普通文件")
}
b, e := root.ReadFile(path)
if e == nil {
return b, nil
}
if !errors.Is(e, os.ErrNotExist) {
return nil, e
}
b, e = generate()
if e != nil {
return nil, e
}
f, e := root.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
return nil, e
}
_, e = f.Write(b)
if e == nil {
e = f.Sync()
}
ce := f.Close()
if e == nil {
e = ce
}
if e == nil {
e = syncDir(root, filepath.Dir(path))
}
return b, e
}
func syncDir(root *os.Root, path string) error {
f, e := root.Open(path)
if e != nil {
return e
}
defer f.Close()
return f.Sync()
}
func (a *App) WaitRequests() { a.requestWG.Wait() }
func (a *App) Close() error {
var e error
if a.db != nil {
e = a.db.Close()
}
if a.root != nil {
a.root.Close()
}
if a.lock != nil {
a.lock.Close()
}
return e
}
func (a *App) policy() (Policy, error) {
var s string
p := Policy{}
e := a.db.QueryRow("SELECT json FROM settings WHERE id=1").Scan(&s)
if e == nil {
e = json.Unmarshal([]byte(s), &p)
}
if e == nil {
e = p.Validate()
}
return p, e
}
func (a *App) audit(action, target, source string) {
tx, e := a.db.Begin()
if e != nil {
a.log.Error("audit failed", "error", e)
return
}
defer tx.Rollback()
_, e = tx.Exec("INSERT INTO audit(created,action,target,source) VALUES(?,?,?,?)", a.now().Unix(), action, target, source)
if e == nil {
e = a.trimAuditTx(tx)
}
if e == nil {
e = tx.Commit()
}
if e != nil {
a.log.Error("audit failed", "action", action, "error", e)
}
}
func (a *App) SetAdmin(username, password string) error {
if len(username) < 1 || len(username) > 64 {
return errors.New("管理员名称范围错误")
}
h, e := hashPassword(password)
if e != nil {
return e
}
tx, e := a.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e = tx.Exec("INSERT INTO admins VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET username=excluded.username,password_hash=excluded.password_hash", username, h); e != nil {
return e
}
if _, e = tx.Exec("DELETE FROM admin_sessions"); e != nil {
return e
}
return tx.Commit()
}
func (a *App) Initialized() bool {
var n int
a.db.QueryRow("SELECT count(*) FROM admins").Scan(&n)
return n == 1
}
func (a *App) Handler(static http.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/health", a.health)
mux.HandleFunc("GET /api/v1/policy", a.publicPolicy)
mux.HandleFunc("POST /api/v1/uploads", a.createUpload)
mux.HandleFunc("PUT /api/v1/uploads/{parcel}/files/{file}", a.uploadFile)
mux.HandleFunc("POST /api/v1/uploads/{parcel}/publish", a.publish)
mux.HandleFunc("GET /api/v1/uploads/{parcel}", a.uploadStatus)
mux.HandleFunc("GET /api/v1/mine", a.mine)
mux.HandleFunc("POST /api/v1/parcels/{parcel}/revoke", a.revoke)
mux.HandleFunc("POST /api/v1/parcels/{parcel}/code", a.regenerate)
mux.HandleFunc("POST /api/v1/pickup/resolve", a.resolve)
mux.HandleFunc("GET /api/v1/pickup", a.pickupHandler)
mux.HandleFunc("GET /api/v1/pickup/files/{file}/download", a.download)
mux.HandleFunc("POST /api/v1/admin/login", a.adminLogin)
mux.HandleFunc("POST /api/v1/admin/logout", a.adminLogout)
mux.HandleFunc("GET /api/v1/admin/status", a.adminStatus)
mux.HandleFunc("GET /api/v1/admin/stats", a.adminStats)
mux.HandleFunc("GET /api/v1/admin/runtime", a.adminRuntime)
mux.HandleFunc("PUT /api/v1/admin/runtime", a.adminRuntime)
mux.HandleFunc("GET /api/v1/admin/parcels", a.adminParcels)
mux.HandleFunc("POST /api/v1/admin/parcels/{parcel}/delete", a.adminDelete)
mux.HandleFunc("GET /api/v1/admin/policy", a.adminPolicy)
mux.HandleFunc("PUT /api/v1/admin/policy", a.adminPolicy)
mux.HandleFunc("GET /api/v1/admin/audit", a.adminAudit)
mux.HandleFunc("POST /api/v1/admin/cleanup", a.adminCleanup)
mux.HandleFunc("POST /api/v1/admin/files/{file}/delete", a.adminDeleteFile)
mux.HandleFunc("POST /api/v1/admin/parcels/{parcel}/reissue", a.adminReissue)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { fail(w, 404, "接口不存在") })
mux.Handle("/", static)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.requestWG.Add(1)
defer a.requestWG.Done()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Cache-Control", "no-store")
}
if strings.HasPrefix(r.URL.Path, "/api/") {
var e error
r, e = a.withAccess(r)
if e != nil {
fail(w, 403, e.Error())
return
}
}
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
origin := r.Header.Get("Origin")
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
fail(w, 403, "跨站请求被拒绝")
return
}
if origin == "" && strings.Contains(r.Header.Get("Cookie"), "bb_") {
fail(w, 403, "携带会话的修改请求必须有 Origin")
return
}
}
if !a.Initialized() && r.URL.Path != "/api/v1/health" {
fail(w, 503, "请先在服务器上初始化管理员")
return
}
mux.ServeHTTP(w, r)
})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if e := d.Decode(v); e != nil {
fail(w, 400, "请求格式无效")
return false
}
if e := d.Decode(&struct{}{}); e != io.EOF {
fail(w, 400, "请求包含多余内容")
return false
}
return true
}
func respond(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, status int, message string) {
respond(w, status, map[string]string{"error": message})
}
func (a *App) serverError(w http.ResponseWriter, e error) {
a.log.Error("operation failed", "error", e)
fail(w, 500, "服务器操作失败,请稍后重试")
}
func (a *App) health(w http.ResponseWriter, r *http.Request) {
if e := a.db.Ping(); e != nil {
fail(w, 503, "数据库不可用")
return
}
respond(w, 200, map[string]any{"ready": a.Initialized(), "version": "0.1.0"})
}
func (a *App) publicPolicy(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "policy", 60) {
fail(w, 429, "访问过于频繁")
return
}
a.mu.Lock()
defer a.mu.Unlock()
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
visitor, e := a.visitor(w, r)
if e != nil {
fail(w, 429, "匿名会话暂不可用")
return
}
var visitorExpires int64
if e = a.db.QueryRow("SELECT expires FROM visitors WHERE id=?", visitor).Scan(&visitorExpires); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, map[string]any{"policy": p, "public_url": a.accessFor(r).base, "access_urls": a.cfg.accessOrigins(), "encryption": a.cfg.Encryption, "deduplication": a.cfg.Deduplication, "visitor_expires": visitorExpires})
}
func (a *App) RunMaintenance(stop <-chan struct{}) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
if e := a.Cleanup(); e != nil {
a.log.Error("cleanup failed", "error", e)
}
}
}
}
func (a *App) Check() error {
var result string
if e := a.db.QueryRow("PRAGMA integrity_check").Scan(&result); e != nil {
return e
}
if result != "ok" {
return fmt.Errorf("SQLite: %s", result)
}
return a.verifyObjects()
}