144 lines
4.7 KiB
Go
144 lines
4.7 KiB
Go
package app
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
)
|
||
|
||
type RuntimeSettings struct {
|
||
FileBudget int64 `json:"file_budget"`
|
||
DataBudget int64 `json:"data_budget"`
|
||
MinFreeBytes int64 `json:"min_free_bytes"`
|
||
AuditRetentionDays int `json:"audit_retention_days"`
|
||
AuditMaxRecords int `json:"audit_max_records"`
|
||
AuditMaxBytes int64 `json:"audit_max_bytes"`
|
||
}
|
||
|
||
func (c Config) InitialRuntimeSettings() RuntimeSettings {
|
||
return RuntimeSettings{c.FileBudget, c.DataBudget, c.MinFreeBytes, c.AuditRetentionDays, c.AuditMaxRecords, c.AuditMaxBytes}
|
||
}
|
||
func (s RuntimeSettings) Validate() error {
|
||
if s.FileBudget < 1 || s.FileBudget > 1<<50 || s.DataBudget > 1<<51 || s.DataBudget-s.FileBudget < 64<<20 || s.MinFreeBytes < 0 || s.MinFreeBytes > 1<<50 {
|
||
return errors.New("文件预算须为正数,数据预算须另留至少64MiB维护空间")
|
||
}
|
||
if s.AuditRetentionDays < 1 || s.AuditRetentionDays > 3650 || s.AuditMaxRecords < 1 || s.AuditMaxRecords > 1000000 || s.AuditMaxBytes < 1024 || s.AuditMaxBytes > 1<<30 || s.AuditMaxBytes > s.DataBudget-s.FileBudget {
|
||
return errors.New("审计需保留1–3650天,条数1–100万,内容预算1KiB–1GiB且不超过维护空间")
|
||
}
|
||
return nil
|
||
}
|
||
func readRuntime(q interface{ QueryRow(string, ...any) *sql.Row }) (RuntimeSettings, error) {
|
||
var text string
|
||
s := RuntimeSettings{}
|
||
e := q.QueryRow("SELECT json FROM runtime_settings WHERE id=1").Scan(&text)
|
||
if e == nil {
|
||
e = json.Unmarshal([]byte(text), &s)
|
||
}
|
||
if e == nil {
|
||
e = s.Validate()
|
||
}
|
||
return s, e
|
||
}
|
||
func (a *App) runtimeSettings() (RuntimeSettings, error) { return readRuntime(a.db) }
|
||
func (a *App) adminRuntime(w http.ResponseWriter, r *http.Request) {
|
||
if !a.requireAdmin(w, r) {
|
||
return
|
||
}
|
||
if r.Method == "GET" {
|
||
s, e := a.runtimeSettings()
|
||
if e != nil {
|
||
a.serverError(w, e)
|
||
return
|
||
}
|
||
respond(w, 200, s)
|
||
return
|
||
}
|
||
var settings RuntimeSettings
|
||
if !decode(w, r, &settings) {
|
||
return
|
||
}
|
||
if e := settings.Validate(); e != nil {
|
||
fail(w, 400, e.Error())
|
||
return
|
||
}
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
b, e := json.Marshal(settings)
|
||
if e != nil {
|
||
a.serverError(w, e)
|
||
return
|
||
}
|
||
tx, e := a.db.Begin()
|
||
if e != nil {
|
||
a.serverError(w, e)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
_, e = tx.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b))
|
||
if e == nil {
|
||
e = a.trimAuditTx(tx)
|
||
}
|
||
if e == nil {
|
||
e = tx.Commit()
|
||
}
|
||
if e != nil {
|
||
a.serverError(w, e)
|
||
return
|
||
}
|
||
a.audit("runtime.update", "", a.remoteIP(r))
|
||
respond(w, 200, settings)
|
||
}
|
||
|
||
// The content budget includes UTF-8 fields and a conservative per-record allowance.
|
||
// SQLite reuses freed pages; this budget is not the physical database-file size.
|
||
const auditRecordBytes = "length(CAST(action AS BLOB))+length(CAST(target AS BLOB))+length(CAST(source AS BLOB))+96"
|
||
|
||
func (a *App) initializeAuditUsage() error {
|
||
expression := func(prefix string) string {
|
||
s := auditRecordBytes
|
||
for _, name := range []string{"action", "target", "source"} {
|
||
s = strings.ReplaceAll(s, name, prefix+"."+name)
|
||
}
|
||
return s
|
||
}
|
||
_, e := a.db.Exec("CREATE TABLE IF NOT EXISTS audit_usage(id INTEGER PRIMARY KEY CHECK(id=1),records INTEGER NOT NULL,content_bytes INTEGER NOT NULL); INSERT OR IGNORE INTO audit_usage SELECT 1,count(*),COALESCE(sum(" + auditRecordBytes + "),0) FROM audit; CREATE TRIGGER IF NOT EXISTS audit_insert_usage AFTER INSERT ON audit BEGIN UPDATE audit_usage SET records=records+1,content_bytes=content_bytes+(" + expression("NEW") + ") WHERE id=1; END; CREATE TRIGGER IF NOT EXISTS audit_delete_usage AFTER DELETE ON audit BEGIN UPDATE audit_usage SET records=records-1,content_bytes=content_bytes-(" + expression("OLD") + ") WHERE id=1; END;")
|
||
return e
|
||
}
|
||
|
||
func (a *App) trimAuditTx(tx *sql.Tx) error {
|
||
s, e := readRuntime(tx)
|
||
if e != nil {
|
||
return e
|
||
}
|
||
if _, e = tx.Exec("DELETE FROM audit WHERE created<?", a.now().Unix()-int64(s.AuditRetentionDays)*86400); e != nil {
|
||
return e
|
||
}
|
||
var count, bytes int64
|
||
if e = tx.QueryRow("SELECT records,content_bytes FROM audit_usage WHERE id=1").Scan(&count, &bytes); e != nil {
|
||
return e
|
||
}
|
||
if count > int64(s.AuditMaxRecords) {
|
||
if _, e = tx.Exec("DELETE FROM audit WHERE id IN(SELECT id FROM audit ORDER BY id DESC LIMIT -1 OFFSET ?)", s.AuditMaxRecords); e != nil {
|
||
return e
|
||
}
|
||
}
|
||
if bytes <= s.AuditMaxBytes {
|
||
return nil
|
||
}
|
||
_, e = tx.Exec("WITH sizes AS (SELECT id,sum("+auditRecordBytes+") OVER(ORDER BY id DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS bytes FROM audit) DELETE FROM audit WHERE id IN(SELECT id FROM sizes WHERE bytes>?)", s.AuditMaxBytes)
|
||
return e
|
||
}
|
||
func (a *App) trimAudit() error {
|
||
tx, e := a.db.Begin()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer tx.Rollback()
|
||
if e = a.trimAuditTx(tx); e != nil {
|
||
return e
|
||
}
|
||
return tx.Commit()
|
||
}
|