248 lines
7.1 KiB
Go
248 lines
7.1 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"golang.org/x/sys/unix"
|
|
"net/http"
|
|
)
|
|
|
|
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|
if !a.isAdmin(r) {
|
|
fail(w, 401, "请先登录管理员")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) {
|
|
if !a.allow(r, "login", 5) {
|
|
fail(w, 429, "登录尝试过于频繁")
|
|
return
|
|
}
|
|
var in struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
select {
|
|
case a.passwordSlots <- struct{}{}:
|
|
default:
|
|
fail(w, 429, "登录繁忙")
|
|
return
|
|
}
|
|
defer func() { <-a.passwordSlots }()
|
|
var username, hash string
|
|
e := a.db.QueryRow("SELECT username,password_hash FROM admins WHERE id=1").Scan(&username, &hash)
|
|
valid := e == nil && verifyPassword(in.Password, hash) && in.Username == username
|
|
if !valid {
|
|
a.audit("admin.login_failed", "", a.remoteIP(r))
|
|
fail(w, 401, "账号或密码错误")
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if _, e = a.db.Exec("DELETE FROM admin_sessions WHERE expires<=? OR token_hash IN(SELECT token_hash FROM admin_sessions ORDER BY expires DESC LIMIT -1 OFFSET 49)", a.now().Unix()); e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
token := randomToken()
|
|
_, e = a.db.Exec("INSERT INTO admin_sessions VALUES(?,?)", tokenHash(token), a.now().Unix()+8*3600)
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
a.cookie(w, r, "bb_admin", token, "/api/v1/admin", 8*3600)
|
|
a.audit("admin.login", "", a.remoteIP(r))
|
|
respond(w, 200, map[string]bool{"authenticated": true})
|
|
}
|
|
func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) {
|
|
_, e := a.db.Exec("DELETE FROM admin_sessions WHERE token_hash=?", tokenHash(cookieValue(r, "bb_admin")))
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
a.cookie(w, r, "bb_admin", "", "/api/v1/admin", -1)
|
|
respond(w, 200, map[string]bool{"authenticated": false})
|
|
}
|
|
func (a *App) adminStatus(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, 200, map[string]bool{"authenticated": a.isAdmin(r)})
|
|
}
|
|
func (a *App) adminStats(w http.ResponseWriter, r *http.Request) {
|
|
if !a.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
used, reserved, meta, e := a.capacity()
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
var raw int64
|
|
var files, objects, parcels int
|
|
queries := []struct {
|
|
sql string
|
|
dst any
|
|
}{{"SELECT COALESCE(sum(size),0) FROM files WHERE object_id IS NOT NULL", &raw}, {"SELECT count(*) FROM files", &files}, {"SELECT count(*) FROM objects", &objects}, {"SELECT count(*) FROM parcels WHERE state='ready'", &parcels}}
|
|
for _, q := range queries {
|
|
if e = a.db.QueryRow(q.sql).Scan(q.dst); e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
}
|
|
limits, e := a.runtimeSettings()
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
var disk unix.Statfs_t
|
|
if e = unix.Statfs(a.cfg.DataDir, &disk); e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
var auditCount, auditBytes int64
|
|
if e = a.db.QueryRow("SELECT records,content_bytes FROM audit_usage WHERE id=1").Scan(&auditCount, &auditBytes); e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
a.transferMu.Lock()
|
|
up, down := a.transfers["upload"], a.transfers["download"]
|
|
a.transferMu.Unlock()
|
|
respond(w, 200, map[string]any{"object_bytes": used, "reserved_bytes": reserved, "metadata_bytes": meta, "logical_bytes": raw, "objects": objects, "files": files, "active_parcels": parcels, "data_budget": limits.DataBudget, "file_budget": limits.FileBudget, "min_free_bytes": limits.MinFreeBytes, "disk_total_bytes": int64(disk.Blocks) * int64(disk.Bsize), "disk_free_bytes": int64(disk.Bavail) * int64(disk.Bsize), "audit_records": auditCount, "audit_content_bytes": auditBytes, "active_uploads": up, "active_downloads": down, "encryption": a.cfg.Encryption, "deduplication": a.cfg.Deduplication})
|
|
}
|
|
func (a *App) adminDelete(w http.ResponseWriter, r *http.Request) {
|
|
if !a.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
id := r.PathValue("parcel")
|
|
res, e := a.db.Exec("UPDATE parcels SET state='revoked',version=version+1 WHERE id=?", id)
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
fail(w, 404, "包裹不存在")
|
|
return
|
|
}
|
|
a.audit("admin.delete", id, a.remoteIP(r))
|
|
respond(w, 200, map[string]any{"revoked": true, "message": "已禁止新下载,实体文件由清理任务回收"})
|
|
}
|
|
func (a *App) adminPolicy(w http.ResponseWriter, r *http.Request) {
|
|
if !a.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
if r.Method == "GET" {
|
|
p, e := a.policy()
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
respond(w, 200, p)
|
|
return
|
|
}
|
|
var p Policy
|
|
if !decode(w, r, &p) {
|
|
return
|
|
}
|
|
if e := p.Validate(); e != nil {
|
|
fail(w, 400, e.Error())
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
b, e := json.Marshal(p)
|
|
if e == nil {
|
|
_, e = a.db.Exec("UPDATE settings SET json=? WHERE id=1", string(b))
|
|
}
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
a.audit("policy.update", "", a.remoteIP(r))
|
|
respond(w, 200, p)
|
|
}
|
|
func (a *App) adminCleanup(w http.ResponseWriter, r *http.Request) {
|
|
if !a.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
if e := a.Cleanup(); e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
a.audit("admin.cleanup", "", a.remoteIP(r))
|
|
respond(w, 200, map[string]bool{"cleaned": true})
|
|
}
|
|
|
|
func (a *App) adminDeleteFile(w http.ResponseWriter, r *http.Request) {
|
|
if !a.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
fid := r.PathValue("file")
|
|
res, e := a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", fid)
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
fail(w, 404, "文件不存在")
|
|
return
|
|
}
|
|
a.audit("admin.file_delete", fid, a.remoteIP(r))
|
|
respond(w, 200, map[string]bool{"deleted": true})
|
|
}
|
|
func (a *App) adminReissue(w http.ResponseWriter, r *http.Request) {
|
|
if !a.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
id := r.PathValue("parcel")
|
|
var state string
|
|
if e := a.db.QueryRow("SELECT state FROM parcels WHERE id=?", id).Scan(&state); e != nil || state != "restored" {
|
|
fail(w, 409, "仅允许重新发布恢复待审核包裹")
|
|
return
|
|
}
|
|
p, e := a.policy()
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
now := a.now().Unix()
|
|
expiry := now + int64(p.DefaultDays)*86400
|
|
hard := now + int64(p.MaxLifetimeDays)*86400
|
|
code := pickupCode()
|
|
tx, e := a.db.Begin()
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
res, e := tx.Exec("UPDATE files SET status='ready',remaining=?,expires=?,hard_expires=? WHERE parcel=? AND object_id IS NOT NULL AND status NOT IN('missing','deleted','deleting')", p.DefaultDownloads, expiry, hard, id)
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
fail(w, 409, "没有可重新发布的文件")
|
|
return
|
|
}
|
|
_, e = tx.Exec("UPDATE parcels SET state='ready',created=?,expires=?,hard_expires=?,code_hash=?,version=version+1,renewal_days=?,renewal_mode=? WHERE id=?", now, expiry, hard, a.keyed(code), p.RenewalDays, p.RenewalMode, id)
|
|
if e == nil {
|
|
e = tx.Commit()
|
|
}
|
|
if e != nil {
|
|
a.serverError(w, e)
|
|
return
|
|
}
|
|
a.audit("admin.reissue", id, a.remoteIP(r))
|
|
respond(w, 200, a.shareResult(r, code, expiry))
|
|
}
|