初始化蓝鸟文件快递柜源码与部署文档
This commit is contained in:
commit
5643d22242
70 files changed
+18669
No files matched your search
@@ -0,0 +1,471 @@
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user