Files

144 lines
4.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}