初始化蓝鸟文件快递柜源码与部署文档

This commit is contained in:
lhc committed 2026-10-05 23:41:07 +08:00
commit 5643d22242
70 files changed
+18669

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
package app
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strconv"
"strings"
)
func normalizeAccessURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || (u.Scheme != "http" && u.Scheme != "https") {
return "", errors.New("访问地址须为无路径的http/https站点地址")
}
host := strings.ToLower(u.Hostname())
port := u.Port()
if port != "" {
n, e := strconv.Atoi(port)
if e != nil || n < 1 || n > 65535 {
return "", errors.New("访问端口须为1至65535")
}
}
if port == "80" && u.Scheme == "http" || port == "443" && u.Scheme == "https" {
port = ""
}
if port != "" {
host = net.JoinHostPort(host, port)
} else if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return u.Scheme + "://" + host, nil
}
func (c Config) accessOrigins() []string {
out := []string{}
for _, raw := range append([]string{c.PublicURL}, c.AccessURLs...) {
base, err := normalizeAccessURL(raw)
if err != nil {
continue
}
found := false
for _, v := range out {
if v == base {
found = true
break
}
}
if !found {
out = append(out, base)
}
}
return out
}
type requestAccessKey struct{}
type requestAccess struct {
base string
httpCookie bool
}
func (a *App) accessFor(r *http.Request) requestAccess {
if state, ok := r.Context().Value(requestAccessKey{}).(requestAccess); ok {
return state
}
return requestAccess{base: strings.TrimRight(a.cfg.PublicURL, "/")}
}
func (a *App) selectAccess(r *http.Request) (requestAccess, error) {
origins := a.cfg.accessOrigins()
allowed := func(raw string) string {
normalized, err := normalizeAccessURL(raw)
if err != nil {
return ""
}
for _, v := range origins {
if v == normalized {
return v
}
}
return ""
}
origin, hint := r.Header.Get("Origin"), r.Header.Get("X-Bluebird-Origin")
selected := ""
if origin != "" {
selected = allowed(origin)
if selected == "" {
return requestAccess{}, errors.New("请求来源不允许")
}
}
if hint != "" {
base := allowed(hint)
if base == "" || selected != "" && base != selected {
return requestAccess{}, errors.New("访问入口不允许")
}
selected = base
}
if selected == "" {
host, scheme := r.Host, ""
if r.TLS != nil {
scheme = "https"
}
peer, _, err := net.SplitHostPort(r.RemoteAddr)
ip := net.ParseIP(peer)
trusted := false
if err == nil {
for _, network := range a.proxies {
if network.Contains(ip) {
trusted = true
break
}
}
}
if trusted {
if forwarded := r.Header.Get("X-Forwarded-Host"); forwarded != "" && !strings.Contains(forwarded, ",") {
host = forwarded
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
scheme = proto
}
}
for _, base := range origins {
u, _ := url.Parse(base)
if strings.EqualFold(u.Host, host) && (scheme == "" || scheme == u.Scheme) {
selected = base
break
}
}
}
if selected == "" {
selected = origins[0]
}
if r.TLS != nil && strings.HasPrefix(selected, "http://") {
return requestAccess{}, errors.New("访问入口协议与连接不一致")
}
hasHTTPS := false
for _, base := range origins {
if strings.HasPrefix(base, "https://") {
hasHTTPS = true
break
}
}
return requestAccess{base: selected, httpCookie: hasHTTPS && strings.HasPrefix(selected, "http://")}, nil
}
func (a *App) withAccess(r *http.Request) (*http.Request, error) {
state, err := a.selectAccess(r)
if err != nil {
return nil, err
}
return r.WithContext(context.WithValue(r.Context(), requestAccessKey{}, state)), nil
}
func cookieNames(r *http.Request, name string) []string {
state, _ := r.Context().Value(requestAccessKey{}).(requestAccess)
if state.httpCookie && (r.Header.Get("Origin") != "" || r.Header.Get("X-Bluebird-Origin") != "") {
return []string{name + "_http", name}
}
return []string{name, name + "_http"}
}
// LANListen accepts only a concrete private or loopback HTTP address.
func LANListen(raw string) (string, string, error) {
base, err := normalizeAccessURL(raw)
if err != nil {
return "", "", err
}
u, _ := url.Parse(base)
ip := net.ParseIP(u.Hostname())
if u.Scheme != "http" || ip == nil || !ip.IsPrivate() && !ip.IsLoopback() || u.Port() == "" {
return "", "", errors.New("内网入口须为http://具体私有IP:端口")
}
return u.Host, base, nil
}
+78
View File
@@ -0,0 +1,78 @@
package app
import (
"bytes"
"os"
"path/filepath"
"syscall"
"github.com/pelletier/go-toml/v2"
)
// UpdateAccessConfig preserves all existing settings, ownership and permissions.
// It never opens the data directory; callers can prepare a candidate while serving.
func UpdateAccessConfig(path, listen string, additions []string, allowLAN bool) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
cfg := DefaultConfig()
if err = toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&cfg); err != nil {
return err
}
if listen != "" {
cfg.Listen = listen
}
cfg.AllowLANHTTP = cfg.AllowLANHTTP || allowLAN
for _, raw := range additions {
base, e := normalizeAccessURL(raw)
if e != nil {
return e
}
found := false
for _, existing := range cfg.accessOrigins() {
if base == existing {
found = true
break
}
}
if !found {
cfg.AccessURLs = append(cfg.AccessURLs, base)
}
}
if err = cfg.Validate(); err != nil {
return err
}
encoded, err := toml.Marshal(cfg)
if err != nil {
return err
}
out, err := os.CreateTemp(filepath.Dir(path), ".access-config-*.toml")
if err != nil {
return err
}
defer os.Remove(out.Name())
defer out.Close()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
if err = out.Chown(int(stat.Uid), int(stat.Gid)); err != nil {
return err
}
}
if err = out.Chmod(info.Mode().Perm()); err != nil {
return err
}
if _, err = out.Write(encoded); err != nil {
return err
}
if err = out.Sync(); err != nil {
return err
}
if err = out.Close(); err != nil {
return err
}
return os.Rename(out.Name(), path)
}
+227
View File
@@ -0,0 +1,227 @@
package app
import (
"bytes"
"encoding/json"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/pelletier/go-toml/v2"
)
func TestMultipleAccessOriginsAndProtocolCookies(t *testing.T) {
c := fixture(t, true)
c.a.cfg.PublicURL = "https://files.example.com"
c.a.cfg.AccessURLs = []string{"http://files.example.com", "http://192.168.1.210:8080"}
c.a.cfg.SecureCookies = true
c.a.cfg.AllowLANHTTP = true
c.a.cfg.Listen = "192.168.1.210:8080"
if err := c.a.cfg.Validate(); err != nil {
t.Fatal(err)
}
jar, _ := cookiejar.New(nil)
request := func(base, method, path string, body any, hint bool) *httptest.ResponseRecorder {
var data []byte
if body != nil {
switch v := body.(type) {
case []byte:
data = v
default:
data, _ = json.Marshal(v)
}
}
r := httptest.NewRequest(method, base+"/api/v1"+path, bytes.NewReader(data))
r.TLS = nil // Real HTTPS is terminated on a separate proxy, which rewrites Host.
r.Host = "192.168.1.210:8080"
if base == "https://files.example.com" {
r.RemoteAddr = "192.168.1.60:1000"
} else if base == "http://files.example.com" {
r.RemoteAddr = "192.168.1.61:1000"
} else {
r.RemoteAddr = "192.168.1.62:1000"
}
if method != "GET" {
r.Header.Set("Origin", base)
}
if hint {
r.Header.Set("X-Bluebird-Origin", base)
}
u, _ := url.Parse(base + "/api/v1" + path)
for _, cookie := range jar.Cookies(u) {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
jar.SetCookies(u, w.Result().Cookies())
return w
}
for _, base := range c.a.cfg.accessOrigins() {
w := request(base, "GET", "/policy", nil, true)
expect(t, w, 200)
var policy map[string]any
json.Unmarshal(w.Body.Bytes(), &policy)
if policy["public_url"] != base {
t.Fatal("policy forced a different origin")
}
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatal("visitor cookie not returned")
}
https := strings.HasPrefix(base, "https:")
if cookies[0].Secure != https {
t.Fatalf("incorrect Secure flag for %s", base)
}
want := "bb_visitor"
if !https {
want += "_http"
}
if cookies[0].Name != want {
t.Fatal("HTTP can overwrite HTTPS cookie")
}
w = request(base, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}, true)
expect(t, w, 200)
if w.Result().Cookies()[0].Secure != https {
t.Fatal("admin cookie protocol incorrect")
}
expect(t, request(base, "GET", "/admin/stats", nil, true), 200)
}
// HTTPS and HTTP on the same public hostname retain independent sessions.
u, _ := url.Parse("http://files.example.com/api/v1/admin/stats")
for _, cookie := range jar.Cookies(u) {
if cookie.Name == "bb_admin" {
t.Fatal("HTTPS credential sent over HTTP")
}
}
payload := []byte("available with failed public DNS")
w := request("http://192.168.1.210:8080", "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "offline.txt", Size: int64(len(payload))}}, Days: 7, Downloads: 8, Renew: true}, true)
expect(t, w, 201)
var parcel struct {
ID string
Files []struct{ ID string }
}
json.Unmarshal(w.Body.Bytes(), &parcel)
expect(t, request("http://192.168.1.210:8080", "PUT", "/uploads/"+parcel.ID+"/files/"+parcel.Files[0].ID, payload, true), 200)
w = request("http://192.168.1.210:8080", "POST", "/uploads/"+parcel.ID+"/publish", map[string]any{}, true)
expect(t, w, 200)
var share struct {
Code, URL string
URLs []map[string]string
}
json.Unmarshal(w.Body.Bytes(), &share)
if !strings.HasPrefix(share.URL, "http://192.168.1.210:8080/") || len(share.URLs) != 3 {
t.Fatal("share did not offer independent LAN/public URLs")
}
for _, base := range c.a.cfg.accessOrigins() {
expect(t, request(base, "POST", "/pickup/resolve", map[string]string{"code": share.Code}, true), 200)
// Navigation downloads have neither Origin nor the frontend access hint.
w = request(base, "GET", "/pickup/files/"+parcel.Files[0].ID+"/download", nil, false)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), payload) {
t.Fatal("cross-entry download failed")
}
}
// The same hostname can hold different pickup grants on HTTP and HTTPS.
other := []byte("a different public HTTP parcel")
w = request("http://files.example.com", "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "other.txt", Size: int64(len(other))}}, Days: 7, Downloads: 4}, true)
expect(t, w, 201)
var second struct {
ID string
Files []struct{ ID string }
}
json.Unmarshal(w.Body.Bytes(), &second)
expect(t, request("http://files.example.com", "PUT", "/uploads/"+second.ID+"/files/"+second.Files[0].ID, other, true), 200)
w = request("http://files.example.com", "POST", "/uploads/"+second.ID+"/publish", map[string]any{}, true)
expect(t, w, 200)
var otherShare struct{ Code string }
json.Unmarshal(w.Body.Bytes(), &otherShare)
expect(t, request("https://files.example.com", "POST", "/pickup/resolve", map[string]string{"code": share.Code}, true), 200)
expect(t, request("http://files.example.com", "POST", "/pickup/resolve", map[string]string{"code": otherShare.Code}, true), 200)
w = request("https://files.example.com", "GET", "/pickup/files/"+parcel.Files[0].ID+"/download", nil, false)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), payload) {
t.Fatal("HTTP grant shadowed the HTTPS grant")
}
w = request("http://files.example.com", "GET", "/pickup/files/"+second.Files[0].ID+"/download", nil, false)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), other) {
t.Fatal("HTTPS grant shadowed the HTTP grant")
}
for _, header := range []struct{ origin, hint string }{{"https://evil.example", ""}, {"", "http://attacker.example"}, {"https://files.example.com", "http://files.example.com"}, {"http://192.168.1.211:8080", ""}} {
r := httptest.NewRequest("POST", "http://192.168.1.210:8080/api/v1/admin/login", strings.NewReader(`{"username":"admin","password":"test-password"}`))
r.Header.Set("Origin", header.origin)
r.Header.Set("X-Bluebird-Origin", header.hint)
out := httptest.NewRecorder()
c.h.ServeHTTP(out, r)
expect(t, out, 403)
}
// Neither Host nor forwarded headers can inject a foreign share URL.
r := httptest.NewRequest("GET", "http://attacker.example/api/v1/policy", nil)
r.RemoteAddr = "203.0.113.4:4000"
r.Header.Set("X-Forwarded-Host", "evil.example")
r.Header.Set("X-Forwarded-Proto", "http")
out := httptest.NewRecorder()
c.h.ServeHTTP(out, r)
expect(t, out, 200)
if strings.Contains(out.Body.String(), "evil.example") || strings.Contains(out.Body.String(), "attacker.example") {
t.Fatal("untrusted forwarding/Host used as site URL")
}
}
func TestAccessConfigurationValidationAndAtomicUpdate(t *testing.T) {
cfg := DefaultConfig()
cfg.PublicURL = "https://files.example.com"
cfg.SecureCookies = true
cfg.AccessURLs = []string{"http://files.example.com", "http://192.168.1.210:8080"}
if cfg.Validate() == nil {
t.Fatal("private HTTP accepted without explicit opt-in")
}
cfg.AllowLANHTTP = true
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
for _, bad := range []string{"null", "https://", "https://:443", "http://user:pass@files.example.com", "http://files.example.com/path", "http://files.example.com?x=1", "ftp://files.example.com", "https://files.example.com:70000"} {
cfg.AccessURLs = []string{bad}
if cfg.Validate() == nil {
t.Fatalf("accepted bad origin %q", bad)
}
}
cfg.AccessURLs = nil
directory := t.TempDir()
path := filepath.Join(directory, "config.toml")
original, _ := toml.Marshal(cfg)
if err := os.WriteFile(path, original, 0640); err != nil {
t.Fatal(err)
}
if err := UpdateAccessConfig(path, "192.168.1.210:8080", []string{"http://files.example.com", "http://192.168.1.210:8080"}, true); err != nil {
t.Fatal(err)
}
saved, err := LoadConfig(path)
if err != nil {
t.Fatal(err)
}
if len(saved.AccessURLs) != 2 || saved.PublicURL != cfg.PublicURL || saved.Encryption != cfg.Encryption || saved.FileBudget != cfg.FileBudget || saved.Listen != "192.168.1.210:8080" {
t.Fatal("access update reset existing settings")
}
if _, err = os.Stat(filepath.Join(directory, "var")); !os.IsNotExist(err) {
t.Fatal("access configuration opened runtime data")
}
updated, _ := os.ReadFile(path)
if err = UpdateAccessConfig(path, "", []string{"http://evil.example/path"}, false); err == nil {
t.Fatal("invalid access update accepted")
}
intact, _ := os.ReadFile(path)
if !bytes.Equal(updated, intact) {
t.Fatal("failed update changed live configuration")
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0640 {
t.Fatal("configuration permissions changed")
}
if _, _, err = LANListen("http://files.example.com:8080"); err == nil {
t.Fatal("LAN flag accepted DNS-dependent domain")
}
}
+247
View File
@@ -0,0 +1,247 @@
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))
}
+216
View File
@@ -0,0 +1,216 @@
package app
import (
"database/sql"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
)
type adminListResult struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Pages int `json:"pages"`
}
func adminPaging(q url.Values, defaultSize int) (adminListResult, error) {
p := adminListResult{Items: []map[string]any{}, Page: 1, PageSize: defaultSize, Pages: 1}
for key, dest := range map[string]*int{"page": &p.Page, "page_size": &p.PageSize} {
if raw := q.Get(key); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || (key == "page_size" && n > 100) {
return p, errors.New("页码须为正整数,每页条数须为1至100")
}
*dest = n
}
}
return p, nil
}
func (p *adminListResult) setTotal(total int) {
p.Total = total
p.Pages = max(1, (total+p.PageSize-1)/p.PageSize)
p.Page = min(p.Page, p.Pages)
}
func adminFilter(q url.Values, key string, limit int) (string, error) {
v := strings.TrimSpace(q.Get(key))
if len(v) > limit {
return "", errors.New("搜索条件过长")
}
return v, nil
}
func (a *App) adminParcels(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
q := r.URL.Query()
result, err := adminPaging(q, 10)
if err != nil {
fail(w, 400, err.Error())
return
}
keyword, err := adminFilter(q, "q", 256)
if err != nil {
fail(w, 400, err.Error())
return
}
state, err := adminFilter(q, "state", 32)
if err != nil {
fail(w, 400, err.Error())
return
}
if state != "" && state != "uploading" && state != "ready" && state != "revoked" && state != "expired" && state != "exhausted" && state != "restored" {
fail(w, 400, "包裹状态无效")
return
}
where := " WHERE 1=1"
args := []any{}
if keyword != "" {
where += " AND (instr(lower(p.id),lower(?))>0 OR EXISTS(SELECT 1 FROM files f WHERE f.parcel=p.id AND instr(lower(f.name),lower(?))>0))"
args = append(args, keyword, keyword)
}
if state != "" {
where += " AND p.state=?"
args = append(args, state)
}
// File details use the same connection after the rows are closed. Prevent
// cleanup/publication from changing the selected parcels between those reads.
a.mu.Lock()
defer a.mu.Unlock()
var total int
if err = a.db.QueryRowContext(r.Context(), "SELECT count(*) FROM parcels p"+where, args...).Scan(&total); err != nil {
a.serverError(w, err)
return
}
result.setTotal(total)
listArgs := append(append([]any{}, args...), result.PageSize, (result.Page-1)*result.PageSize)
rows, err := a.db.QueryContext(r.Context(), "SELECT p.id,p.state,p.ip,p.created,p.expires,p.renewal_days,p.renewal_mode,(SELECT count(*) FROM files WHERE parcel=p.id),(SELECT COALESCE(sum(size),0) FROM files WHERE parcel=p.id) FROM parcels p"+where+" ORDER BY p.created DESC,p.id DESC LIMIT ? OFFSET ?", listArgs...)
if err != nil {
a.serverError(w, err)
return
}
for rows.Next() {
var id, state, ip, mode string
var created, expiry, size int64
var renewal, count int
if err = rows.Scan(&id, &state, &ip, &created, &expiry, &renewal, &mode, &count, &size); err != nil {
rows.Close()
a.serverError(w, err)
return
}
result.Items = append(result.Items, map[string]any{"id": id, "state": state, "ip": ip, "created": created, "expires": expiry, "renewal_days": renewal, "renewal_mode": mode, "count": count, "size": size})
}
err = rows.Err()
rows.Close()
if err != nil {
a.serverError(w, err)
return
}
for _, p := range result.Items {
files, err := a.fileList(p["id"].(string))
if err != nil {
a.serverError(w, err)
return
}
p["files"] = files
}
respond(w, 200, result)
}
func (a *App) adminAudit(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
q := r.URL.Query()
result, err := adminPaging(q, 20)
if err != nil {
fail(w, 400, err.Error())
return
}
where := " WHERE 1=1"
args := []any{}
for _, f := range []struct {
key string
limit int
}{{"action", 64}, {"source", 128}, {"target", 256}} {
value, err := adminFilter(q, f.key, f.limit)
if err != nil {
fail(w, 400, err.Error())
return
}
if value == "" {
continue
}
if f.key == "action" {
where += " AND action=?"
} else {
where += " AND instr(lower(" + f.key + "),lower(?))>0"
}
args = append(args, value)
}
var from, to int64
for _, bound := range []struct {
key, op string
dest *int64
}{{"from", ">=", &from}, {"to", "<=", &to}} {
if raw := q.Get(bound.key); raw != "" {
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
fail(w, 400, "时间须为非负Unix秒数")
return
}
*bound.dest = value
where += " AND created" + bound.op + "?"
args = append(args, value)
}
}
if q.Get("from") != "" && q.Get("to") != "" && from > to {
fail(w, 400, "开始时间不能晚于结束时间")
return
}
// Count and items share a snapshot so concurrent audit writes or retention do
// not change the response's total and page boundaries mid-query.
tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{ReadOnly: true})
if err != nil {
a.serverError(w, err)
return
}
defer tx.Rollback()
var total int
if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM audit"+where, args...).Scan(&total); err != nil {
a.serverError(w, err)
return
}
result.setTotal(total)
listArgs := append(append([]any{}, args...), result.PageSize, (result.Page-1)*result.PageSize)
rows, err := tx.QueryContext(r.Context(), "SELECT id,created,action,target,source FROM audit"+where+" ORDER BY id DESC LIMIT ? OFFSET ?", listArgs...)
if err != nil {
a.serverError(w, err)
return
}
for rows.Next() {
var id, created int64
var action, target, source string
if err = rows.Scan(&id, &created, &action, &target, &source); err != nil {
rows.Close()
a.serverError(w, err)
return
}
result.Items = append(result.Items, map[string]any{"id": id, "created": created, "action": action, "target": target, "source": source})
}
err = rows.Err()
rows.Close()
if err != nil {
a.serverError(w, err)
return
}
if err = tx.Commit(); err != nil {
a.serverError(w, err)
return
}
respond(w, 200, result)
}
+175
View File
@@ -0,0 +1,175 @@
package app
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"testing"
)
func adminResult(t *testing.T, c *testClient, path string) adminListResult {
t.Helper()
w := c.request(t, "GET", path, nil)
expect(t, w, 200)
var p adminListResult
if err := json.Unmarshal(w.Body.Bytes(), &p); err != nil {
t.Fatal(err)
}
if p.Items == nil {
t.Fatal("items must be an array, including when empty")
}
return p
}
func TestAdminParcelPaginationAndSearch(t *testing.T) {
c := fixture(t, false)
expect(t, c.request(t, "GET", "/admin/parcels?page_size=10", nil), 401)
loginTestAdmin(t, c)
tx, err := c.a.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
for i := 1; i <= 25; i++ {
id := fmt.Sprintf("%032x", i)
state := "ready"
if i%2 == 0 {
state = "revoked"
}
_, err = tx.Exec("INSERT INTO parcels(id,visitor,ip,state,created,password_hash,manage_hash,days,downloads,renewal_days,renewal_mode,lifetime_days,upload_expires,usage_day) VALUES(?,?,'203.0.113.1',?,1000,'','',1,1,0,'add',30,2000,'2026-10-05')", id, "visitor", state)
if err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("document-%d.txt", i)
if i == 5 {
name = "说明_%_Report.txt"
}
_, err = tx.Exec("INSERT INTO files(id,parcel,name,size,status,reserved,remaining) VALUES(?,?,?,12,'deleted',0,0)", id, id, name)
if err != nil {
t.Fatal(err)
}
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
first := adminResult(t, c, "/admin/parcels")
if first.Total != 25 || first.PageSize != 10 || first.Pages != 3 || len(first.Items) != 10 || first.Items[0]["id"] != fmt.Sprintf("%032x", 25) {
t.Fatalf("bad first page: %+v", first)
}
second := adminResult(t, c, "/admin/parcels?page=2")
if second.Page != 2 || second.Items[0]["id"] != fmt.Sprintf("%032x", 15) {
t.Fatalf("bad second page: %+v", second)
}
last := adminResult(t, c, "/admin/parcels?page=999999999")
if last.Page != 3 || len(last.Items) != 5 {
t.Fatalf("out-of-range page was not clamped: %+v", last)
}
for _, q := range []string{"REPORT", "说明", "%", fmt.Sprintf("%032x", 5)} {
p := adminResult(t, c, "/admin/parcels?q="+url.QueryEscape(q))
if p.Total != 1 || p.Items[0]["id"] != fmt.Sprintf("%032x", 5) {
t.Fatalf("literal filename/ID query %q: %+v", q, p)
}
}
p := adminResult(t, c, "/admin/parcels?state=revoked&page_size=20")
if p.Total != 12 || p.PageSize != 20 || len(p.Items) != 12 {
t.Fatalf("state filter: %+v", p)
}
p = adminResult(t, c, "/admin/parcels?q="+url.QueryEscape("' OR 1=1 --"))
if p.Total != 0 || len(p.Items) != 0 || p.Page != 1 || p.Pages != 1 {
t.Fatalf("empty/literal injection query: %+v", p)
}
for _, q := range []string{"page=0", "page=-1", "page=oops", "page_size=0", "page_size=101", "page_size=oops", "state=unknown", "q=" + strings.Repeat("a", 257)} {
expect(t, c.request(t, "GET", "/admin/parcels?"+q, nil), 400)
}
}
func TestAdminAuditPaginationAndCombinedFilters(t *testing.T) {
c := fixture(t, false)
expect(t, c.request(t, "GET", "/admin/audit", nil), 401)
loginTestAdmin(t, c)
if _, err := c.a.db.Exec("DELETE FROM audit"); err != nil {
t.Fatal(err)
}
base := c.a.now().Unix()
tx, err := c.a.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
for i := 1; i <= 54; i++ {
action := "upload.create"
if i%2 == 0 {
action = "admin.delete"
}
source := "2001:db8::1"
if i%3 == 0 {
source = "203.0.113.1"
}
target := fmt.Sprintf("job-%d", i)
if i == 18 {
target = "job_%_18"
}
if _, err = tx.Exec("INSERT INTO audit(created,action,target,source) VALUES(?,?,?,?)", base+int64(i)*60, action, target, source); err != nil {
t.Fatal(err)
}
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
p := adminResult(t, c, "/admin/audit")
if p.Total != 54 || p.PageSize != 20 || p.Pages != 3 || len(p.Items) != 20 || p.Items[0]["target"] != "job-54" {
t.Fatalf("bad audit page: %+v", p)
}
p2 := adminResult(t, c, "/admin/audit?page=2")
if p2.Items[0]["target"] != "job-34" {
t.Fatalf("audit page did not change: %+v", p2)
}
last := adminResult(t, c, "/admin/audit?page=99999")
if last.Page != 3 || len(last.Items) != 14 {
t.Fatalf("bad audit last page: %+v", last)
}
query := fmt.Sprintf("/admin/audit?action=admin.delete&source=203.0.113.1&target=JOB&from=%d&to=%d", base+10*60, base+30*60)
filtered := adminResult(t, c, query)
if filtered.Total != 4 || len(filtered.Items) != 4 {
t.Fatalf("combined/inclusive date filters: %+v", filtered)
}
for _, item := range filtered.Items {
if item["action"] != "admin.delete" || item["source"] != "203.0.113.1" {
t.Fatalf("wrong filter result: %+v", item)
}
}
literal := adminResult(t, c, "/admin/audit?target=%25")
if literal.Total != 1 || literal.Items[0]["target"] != "job_%_18" {
t.Fatalf("wildcard interpreted as pattern: %+v", literal)
}
ipv6 := adminResult(t, c, "/admin/audit?source=2001%3ADB8")
if ipv6.Total != 36 {
t.Fatalf("IPv6 fragment filter: %+v", ipv6)
}
empty := adminResult(t, c, "/admin/audit?action=unknown")
if empty.Total != 0 || empty.Pages != 1 || len(empty.Items) != 0 {
t.Fatal("empty audit search malformed")
}
for _, q := range []string{"page=0", "page_size=101", "from=-1", "from=nope", "to=1.5", "from=20&to=10", "action=" + strings.Repeat("a", 65), "source=" + strings.Repeat("a", 129), "target=" + strings.Repeat("a", 257)} {
expect(t, c.request(t, "GET", "/admin/audit?"+q, nil), 400)
}
}
func TestAdminAuditOneHundredThousandRecordsRemainPaged(t *testing.T) {
c := fixture(t, false)
loginTestAdmin(t, c)
_, err := c.a.db.Exec("DELETE FROM audit; WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<100000) INSERT INTO audit(created,action,target,source) SELECT 1000,'upload.create',printf('target-%d',i),'203.0.113.1' FROM n")
if err != nil {
t.Fatal(err)
}
first := adminResult(t, c, "/admin/audit")
if first.Total != 100000 || first.Pages != 5000 || len(first.Items) != 20 || first.Items[0]["target"] != "target-100000" {
t.Fatalf("large audit query unbounded: %+v", first)
}
last := adminResult(t, c, "/admin/audit?page=5000")
if last.Page != 5000 || len(last.Items) != 20 || last.Items[19]["target"] != "target-1" {
t.Fatalf("large audit final page incorrect: %+v", last)
}
filtered := adminResult(t, c, "/admin/audit?target=target-12345")
if filtered.Total != 1 || len(filtered.Items) != 1 || filtered.Items[0]["target"] != "target-12345" {
t.Fatal("search did not include older/all retained audit events")
}
}
+471
View File
@@ -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()
}
+790
View File
@@ -0,0 +1,790 @@
package app
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"hash"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"golang.org/x/time/rate"
)
type testClient struct {
a *App
h http.Handler
cookies map[string]*http.Cookie
ip string
}
func fixture(t *testing.T, encrypted bool) *testClient {
t.Helper()
c := DefaultConfig()
c.DataDir = t.TempDir()
c.Encryption = encrypted
c.MinFreeBytes = 0
c.DataBudget = 128 << 20
c.FileBudget = 64 << 20
a, e := Open(c)
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { a.Close() })
if e = a.SetAdmin("admin", "test-password"); e != nil {
t.Fatal(e)
}
return &testClient{a: a, h: a.Handler(http.NotFoundHandler()), cookies: map[string]*http.Cookie{}, ip: "203.0.113.10"}
}
func (c *testClient) request(t *testing.T, method, path string, body []byte) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, "http://localhost:8080/api/v1"+path, bytes.NewReader(body))
r.RemoteAddr = c.ip + ":1234"
if method != "GET" && method != "HEAD" {
r.Header.Set("Origin", "http://localhost:8080")
}
for _, cookie := range c.cookies {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
for _, cookie := range w.Result().Cookies() {
if cookie.MaxAge < 0 {
delete(c.cookies, cookie.Name)
} else {
c.cookies[cookie.Name] = cookie
}
}
return w
}
func (c *testClient) json(t *testing.T, method, path string, v any) *httptest.ResponseRecorder {
b, e := json.Marshal(v)
if e != nil {
t.Fatal(e)
}
return c.request(t, method, path, b)
}
func expect(t *testing.T, w *httptest.ResponseRecorder, status int) {
t.Helper()
if w.Code != status {
t.Fatalf("status=%d want=%d body=%s", w.Code, status, w.Body.String())
}
}
func (c *testClient) create(t *testing.T, names []string, data [][]byte, downloads int, renew bool) (string, []string) {
t.Helper()
fs := []ManifestFile{}
for i, d := range data {
fs = append(fs, ManifestFile{Name: names[i], Size: int64(len(d))})
}
w := c.json(t, "POST", "/uploads", Manifest{Files: fs, Days: 7, Downloads: downloads, Renew: renew})
expect(t, w, 201)
var result struct {
ID string `json:"id"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if e := json.Unmarshal(w.Body.Bytes(), &result); e != nil {
t.Fatal(e)
}
ids := []string{}
for _, f := range result.Files {
ids = append(ids, f.ID)
}
return result.ID, ids
}
func (c *testClient) publish(t *testing.T, id string) string {
w := c.json(t, "POST", "/uploads/"+id+"/publish", struct{}{})
expect(t, w, 200)
var out struct {
Code string `json:"code"`
}
json.Unmarshal(w.Body.Bytes(), &out)
if len(out.Code) != 10 {
t.Fatal("missing code")
}
return out.Code
}
func (c *testClient) resolve(t *testing.T, code string) {
w := c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code})
expect(t, w, 200)
}
func (c *testClient) upload(t *testing.T, id, fid string, b []byte) {
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+fid, b), 200)
}
func scalar(t *testing.T, a *App, q string, args ...any) int64 {
t.Helper()
var n int64
if e := a.db.QueryRow(q, args...).Scan(&n); e != nil {
t.Fatal(e)
}
return n
}
func TestDedupIndependentPermissionsAndCleanup(t *testing.T) {
for _, encrypted := range []bool{false, true} {
t.Run(map[bool]string{false: "plain", true: "age"}[encrypted], func(t *testing.T) {
c := fixture(t, encrypted)
data := []byte(strings.Repeat("confidential-content", 1000))
id, files := c.create(t, []string{"a.txt", "b.txt"}, [][]byte{data, data}, 3, true)
for _, f := range files {
c.upload(t, id, f, data)
}
code := c.publish(t, id)
if n := scalar(t, c.a, "SELECT count(*) FROM objects"); n != 1 {
t.Fatalf("dedup objects=%d", n)
}
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
payload, _ := c.a.root.ReadFile(objectPath(oid))
if encrypted && bytes.Contains(payload, data) {
t.Fatal("plaintext at rest")
}
c.resolve(t, code)
w := c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), data) {
t.Fatal("roundtrip mismatch")
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 || scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[1]) != 3 {
t.Fatal("download counters not independent")
}
aExpiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
bExpiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[1])
if aExpiry-bExpiry != 3*86400 {
t.Fatalf("renewal delta=%d", aExpiry-bExpiry)
}
_, e := c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[0])
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("deleted shared payload prematurely")
}
_, e = c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[1])
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("unreferenced object leaked")
}
if _, e = c.a.root.Stat(objectPath(oid)); !os.IsNotExist(e) {
t.Fatal("payload not removed")
}
})
}
}
func TestLimitsAndOversizedBody(t *testing.T) {
c := fixture(t, false)
p := c.a.cfg.Policy
p.MaxFiles = 1
p.MaxFileBytes = 4
p.MaxBatchBytes = 4
b, _ := json.Marshal(p)
c.a.db.Exec("UPDATE settings SET json=?", string(b))
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{"a", 3}, {"b", 1}}}), 400)
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{"a", 5}}}), 400)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("1234")}, 2, false)
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], []byte("12345")), 400)
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("oversize object published")
}
expect(t, c.json(t, "POST", "/uploads/"+id+"/publish", struct{}{}), 409)
c.upload(t, id, files[0], []byte("1234"))
c.publish(t, id)
var billed int64
c.a.db.QueryRow("SELECT bytes FROM usage WHERE subject LIKE 'ip:%'").Scan(&billed)
if billed != 8 {
t.Fatalf("retry traffic billed=%d want=8", billed)
}
}
func TestConcurrentLastDownload(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("one")}, 1, true)
c.upload(t, id, files[0], []byte("one"))
c.resolve(t, c.publish(t, id))
cookie := *c.cookies["bb_pickup"]
var wg sync.WaitGroup
codes := make(chan int, 12)
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = "203.0.113.11:1234"
r.AddCookie(&cookie)
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
codes <- w.Code
}()
}
wg.Wait()
close(codes)
success := 0
for n := range codes {
if n == 200 {
success++
}
}
if success != 1 {
t.Fatalf("accepted %d downloads", success)
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 0 {
t.Fatal("counter below zero or unconsumed")
}
if scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0]) != scalar(t, c.a, "SELECT expires FROM parcels WHERE id=?", id) {
t.Fatal("exhausted file renewed")
}
}
func TestConcurrentCapacityReservation(t *testing.T) {
c := fixture(t, false)
c.a.cfg.FileBudget = reserveSize(1024, false) * 2
testRuntimeBudgets(t, c.a, c.a.cfg.FileBudget, c.a.cfg.DataBudget)
c.a.cfg.Policy.IPDailyUploads = 100
c.a.rateMu.Lock()
c.a.rates["upload:"+c.ip] = &rateEntry{limiter: rate.NewLimiter(rate.Inf, 100), touched: time.Now()}
c.a.rateMu.Unlock()
var wg sync.WaitGroup
statuses := make(chan int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
b, _ := json.Marshal(Manifest{Files: []ManifestFile{{"a", 1024}}})
r := httptest.NewRequest("POST", "http://localhost:8080/api/v1/uploads", bytes.NewReader(b))
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = c.ip + ":12"
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
statuses <- w.Code
}()
}
wg.Wait()
close(statuses)
success := 0
for n := range statuses {
if n == 201 {
success++
} else if n != 507 {
t.Fatalf("unexpected %d", n)
}
}
if success != 2 {
t.Fatalf("reserved %d sessions want2", success)
}
_, reserved, _, e := c.a.capacity()
if e != nil || reserved != c.a.cfg.FileBudget {
t.Fatal("reservation ledger inconsistent")
}
}
func TestPasswordOwnershipAndOrigin(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"../../evil.html"}, [][]byte{[]byte("<script>alert(1)</script>")}, 2, false)
other := &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: "203.0.113.20"}
expect(t, other.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], []byte("abc")), 404)
c.upload(t, id, files[0], []byte("<script>alert(1)</script>"))
code := c.publish(t, id)
hash, _ := hashPassword("pickup-password")
c.a.db.Exec("UPDATE parcels SET password_hash=? WHERE id=?", hash, id)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code}), 401)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code, "password": "wrong"}), 404)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code, "password": "pickup-password"}), 200)
w := c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil)
expect(t, w, 200)
if !strings.HasPrefix(w.Header().Get("Content-Disposition"), "attachment") || w.Header().Get("Content-Type") != "application/octet-stream" {
t.Fatal("active content served inline")
}
r := httptest.NewRequest("POST", "http://localhost:8080/api/v1/parcels/"+id+"/revoke", nil)
r.Header.Set("Origin", "https://evil.example")
r.AddCookie(c.cookies["bb_visitor"])
w = httptest.NewRecorder()
c.h.ServeHTTP(w, r)
expect(t, w, 403)
}
func TestRenewalModesAndLifetimeCeiling(t *testing.T) {
for _, mode := range []string{"add", "sliding"} {
t.Run(mode, func(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 4, true)
c.upload(t, id, files[0], []byte("content"))
c.resolve(t, c.publish(t, id))
expiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
hard := expiry + 86400
c.a.db.Exec("UPDATE parcels SET renewal_mode=? WHERE id=?", mode, id)
c.a.db.Exec("UPDATE files SET hard_expires=? WHERE id=?", hard, files[0])
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 200)
actual := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
expected := hard
if mode == "sliding" {
expected = expiry
}
if actual != expected {
t.Fatalf("expiry=%d want%d", actual, expected)
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 3 {
t.Fatal("renewal reset downloads")
}
})
}
}
type failWriter struct {
header http.Header
bytes int64
}
func (w *failWriter) Header() http.Header { return w.header }
func (w *failWriter) WriteHeader(int) {}
func (w *failWriter) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe }
func TestInterruptedDownloadDoesNotRenew(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, true)
c.upload(t, id, files[0], []byte("content"))
c.resolve(t, c.publish(t, id))
expiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = c.ip + ":12"
r.AddCookie(c.cookies["bb_pickup"])
c.h.ServeHTTP(&failWriter{header: make(http.Header)}, r)
if scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0]) != expiry {
t.Fatal("failed download renewed")
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("failed begun download not charged")
}
}
func TestBackupRestoreAndMissingKey(t *testing.T) {
c := fixture(t, true)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, true)
c.upload(t, id, files[0], []byte("content"))
code := c.publish(t, id)
config := filepath.Join(t.TempDir(), "config.toml")
os.WriteFile(config, []byte("listen='127.0.0.1:8080'\n"), 0600)
archive := filepath.Join(t.TempDir(), "backup.tar.gz")
if e := c.a.Backup(config, archive); e != nil {
t.Fatal(e)
}
cfg := c.a.cfg
cfg.DataDir = t.TempDir()
if e := Restore(cfg, archive); e != nil {
t.Fatal(e)
}
restored, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
if scalar(t, restored, "SELECT count(*) FROM objects") != 1 {
t.Fatal("restore lost payload")
}
if scalar(t, restored, "SELECT count(*) FROM parcels WHERE state='restored' AND code_hash IS NULL") != 1 {
t.Fatal("old share remains authorized")
}
if scalar(t, restored, "SELECT count(*) FROM parcels WHERE code_hash=?", restored.keyed(code)) != 0 {
t.Fatal("old code restored")
}
restored.Close()
os.Remove(filepath.Join(cfg.DataDir, "secrets", "age.key"))
if a, e := Open(cfg); e == nil {
a.Close()
t.Fatal("missing encrypted identity silently regenerated")
}
}
func TestRecoveryAndIPPersistence(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, false)
c.upload(t, id, files[0], []byte("content"))
cfg := c.a.cfg
c.a.Close()
c.a.db = nil
c.a.root = nil
c.a.lock = nil
a, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
defer a.Close()
if scalar(t, a, "SELECT count(*) FROM parcels WHERE state='revoked'") != 1 || scalar(t, a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("partial upload became accessible or leaked")
}
if scalar(t, a, "SELECT uploads FROM usage WHERE subject LIKE 'ip:%'") != 1 {
t.Fatal("IP daily counter reset")
}
r := httptest.NewRequest("GET", "http://localhost", nil)
r.RemoteAddr = "203.0.113.21:80"
r.Header.Set("X-Forwarded-For", "8.8.8.8")
if a.remoteIP(r) != "203.0.113.21" {
t.Fatal("untrusted forwarded IP accepted")
}
}
type gatedReader struct {
r *bytes.Reader
once sync.Once
started chan<- struct{}
release <-chan struct{}
}
func (r *gatedReader) Read(p []byte) (int, error) {
r.once.Do(func() { r.started <- struct{}{}; <-r.release })
return r.r.Read(p)
}
type sinkWriter struct {
header http.Header
status int
hash hash.Hash
count int64
once sync.Once
started chan<- struct{}
release <-chan struct{}
}
func (w *sinkWriter) Header() http.Header { return w.header }
func (w *sinkWriter) WriteHeader(n int) { w.status = n }
func (w *sinkWriter) Write(p []byte) (int, error) {
w.once.Do(func() { w.started <- struct{}{}; <-w.release })
if w.status == 0 {
w.status = 200
}
n, e := w.hash.Write(p)
w.count += int64(n)
return n, e
}
func TestConcurrentStreamingTransfers(t *testing.T) {
c := fixture(t, true)
mib := 8
if v, e := strconv.Atoi(os.Getenv("BLUEBIRD_STRESS_MIB")); e == nil && v >= 1 && v <= 100 {
mib = v
}
c.a.cfg.FileBudget = int64(mib*8+64) << 20
c.a.cfg.DataBudget = c.a.cfg.FileBudget + (64 << 20)
testRuntimeBudgets(t, c.a, c.a.cfg.FileBudget, c.a.cfg.DataBudget)
payload := bytes.Repeat([]byte("transfer"), mib*(1<<17))
clients := make([]*testClient, 8)
ids := make([]string, 8)
files := make([]string, 8)
for i := range clients {
clients[i] = &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: fmt.Sprintf("203.0.113.%d", 40+i)}
id, f := clients[i].create(t, []string{"big.bin"}, [][]byte{payload}, 20, true)
ids[i], files[i] = id, f[0]
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
started := make(chan struct{}, 8)
release := make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
var wg sync.WaitGroup
out := make(chan int, 8)
for i := range clients {
wg.Add(1)
go func(i int) {
defer wg.Done()
r := httptest.NewRequest("PUT", "http://localhost:8080/api/v1/uploads/"+ids[i]+"/files/"+files[i], &gatedReader{r: bytes.NewReader(payload), started: started, release: release})
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = clients[i].ip + ":1234"
for _, cookie := range clients[i].cookies {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
out <- w.Code
}(i)
}
for range 8 {
select {
case <-started:
case <-time.After(10 * time.Second):
t.Fatal("8 upload streams did not start together")
}
}
var duringUploads runtime.MemStats
runtime.ReadMemStats(&duringUploads)
uploadStart := time.Now()
releaseOnce.Do(func() { close(release) })
wg.Wait()
uploadDuration := time.Since(uploadStart)
close(out)
for status := range out {
if status != 200 {
t.Fatalf("upload status=%d", status)
}
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("simultaneous identical content not deduplicated")
}
for i := range clients {
clients[i].publish(t, ids[i])
}
var codeHash string
c.a.db.QueryRow("SELECT code_hash FROM parcels WHERE id=?", ids[0]).Scan(&codeHash)
_ = codeHash
code := pickupCode()
c.a.db.Exec("UPDATE parcels SET code_hash=? WHERE id=?", c.a.keyed(code), ids[0])
clients[0].resolve(t, code)
cookie := *clients[0].cookies["bb_pickup"]
downloadStarted := make(chan struct{}, 16)
downloadRelease := make(chan struct{})
var downOnce sync.Once
defer downOnce.Do(func() { close(downloadRelease) })
sinks := make([]*sinkWriter, 16)
for i := range sinks {
sinks[i] = &sinkWriter{header: make(http.Header), hash: sha256.New(), started: downloadStarted, release: downloadRelease}
wg.Add(1)
go func(i int) {
defer wg.Done()
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = fmt.Sprintf("203.0.113.%d:1234", 80+i)
r.AddCookie(&cookie)
c.h.ServeHTTP(sinks[i], r)
}(i)
}
for range 16 {
select {
case <-downloadStarted:
case <-time.After(10 * time.Second):
t.Fatal("16 download streams did not start together")
}
}
var duringDownloads runtime.MemStats
runtime.ReadMemStats(&duringDownloads)
downloadStart := time.Now()
downOnce.Do(func() { close(downloadRelease) })
wg.Wait()
downloadDuration := time.Since(downloadStart)
want := sha256.Sum256(payload)
for _, s := range sinks {
if s.status != 200 || s.count != int64(len(payload)) || !bytes.Equal(s.hash.Sum(nil), want[:]) {
t.Fatal("stream output checksum/count mismatch")
}
}
t.Logf("age enabled; 8 simultaneous ×%dMiB uploads: %s (%.1f MiB/s); 16 simultaneous ×%dMiB downloads: %s (%.1f MiB/s); heap delta at barrier: uploads=%.2fMiB downloads=%.2fMiB; in-process HTTP handlers, excludes network/TLS", mib, uploadDuration, float64(mib*8)/uploadDuration.Seconds(), mib, downloadDuration, float64(mib*16)/downloadDuration.Seconds(), float64(int64(duringUploads.Alloc)-int64(before.Alloc))/(1<<20), float64(int64(duringDownloads.Alloc)-int64(before.Alloc))/(1<<20))
}
func TestInjectedCommitFailureAndDeletionFailure(t *testing.T) {
c := fixture(t, true)
payload := []byte("test fault recovery")
id, files := c.create(t, []string{"a"}, [][]byte{payload}, 3, true)
if _, e := c.a.db.Exec("CREATE TRIGGER reject_object_attach BEFORE UPDATE OF object_id ON files WHEN NEW.object_id IS NOT NULL BEGIN SELECT RAISE(ABORT,'injected commit failure'); END"); e != nil {
t.Fatal(e)
}
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], payload), 500)
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("rolled-back object still registered")
}
entries, e := os.ReadDir(filepath.Join(c.a.cfg.DataDir, "objects"))
if e != nil {
t.Fatal(e)
}
for _, d := range entries {
sub, _ := os.ReadDir(filepath.Join(c.a.cfg.DataDir, "objects", d.Name()))
if len(sub) > 0 {
t.Fatal("failed commit left uncharged payload")
}
}
c.a.db.Exec("DROP TRIGGER reject_object_attach")
c.upload(t, id, files[0], payload)
c.publish(t, id)
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
path := objectPath(oid)
if e = c.a.root.Remove(path); e != nil {
t.Fatal(e)
}
if e = c.a.root.Mkdir(path, 0700); e != nil {
t.Fatal(e)
}
if e = c.a.root.WriteFile(path+"/blocker", []byte("x"), 0600); e != nil {
t.Fatal(e)
}
c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[0])
if e = c.a.Cleanup(); e == nil {
t.Fatal("expected physical deletion failure")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 || scalar(t, c.a, "SELECT sum(stored_bytes) FROM objects") <= 0 {
t.Fatal("failed deletion incorrectly released capacity")
}
c.a.root.Remove(path + "/blocker")
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("retry deletion failed")
}
}
func TestDeleteDuringUploadDoesNotRepublish(t *testing.T) {
c := fixture(t, false)
payload := []byte("delete while uploading")
id, files := c.create(t, []string{"a"}, [][]byte{payload}, 3, false)
started := make(chan struct{}, 1)
release := make(chan struct{})
defer func() {
select {
case <-release:
default:
close(release)
}
}()
r := httptest.NewRequest("PUT", "http://localhost:8080/api/v1/uploads/"+id+"/files/"+files[0], &gatedReader{r: bytes.NewReader(payload), started: started, release: release})
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = c.ip + ":12"
r.AddCookie(c.cookies["bb_visitor"])
done := make(chan *httptest.ResponseRecorder, 1)
go func() { w := httptest.NewRecorder(); c.h.ServeHTTP(w, r); done <- w }()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("stream did not start")
}
expect(t, c.json(t, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}), 200)
expect(t, c.json(t, "POST", "/admin/files/"+files[0]+"/delete", struct{}{}), 200)
_, reserved, _, e := c.a.capacity()
if e != nil || reserved != reserveSize(int64(len(payload)), false) {
t.Fatal("active deletion released upload reservation too early")
}
close(release)
expect(t, <-done, 500)
if scalar(t, c.a, "SELECT count(*) FROM files WHERE status='ready'") != 0 {
t.Fatal("deleted file became ready")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("canceled payload leaked")
}
if e := c.a.Cleanup(); e != nil {
t.Fatal(e)
}
}
func TestAnonymousCookieResetDoesNotResetIPQuota(t *testing.T) {
c := fixture(t, false)
p := c.a.cfg.Policy
p.IPDailyUploads = 1
b, _ := json.Marshal(p)
c.a.db.Exec("UPDATE settings SET json=?", string(b))
c.create(t, []string{"a"}, [][]byte{[]byte("x")}, 1, false)
fresh := &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: c.ip}
expect(t, fresh.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "a", Size: 1}}}), 429)
if scalar(t, c.a, "SELECT uploads FROM usage WHERE subject LIKE 'ip:%'") != 1 {
t.Fatal("daily quota mutated on rejection")
}
}
func TestFailedRestoreCannotStart(t *testing.T) {
cfg := DefaultConfig()
cfg.DataDir = t.TempDir()
cfg.MinFreeBytes = 0
archive := filepath.Join(t.TempDir(), "broken.tar.gz")
os.WriteFile(archive, []byte("not a backup"), 0600)
if e := Restore(cfg, archive); e == nil {
t.Fatal("invalid archive accepted")
}
if a, e := Open(cfg); e == nil {
a.Close()
t.Fatal("partially restored service can start")
}
}
func TestExclusiveLockAndZeroByteIntegrity(t *testing.T) {
c := fixture(t, true)
if another, e := Open(c.a.cfg); e == nil {
another.Close()
t.Fatal("second process lock succeeded")
}
id, files := c.create(t, []string{"empty"}, [][]byte{{}}, 2, true)
c.upload(t, id, files[0], nil)
c.resolve(t, c.publish(t, id))
expect(t, c.request(t, "HEAD", "/pickup/files/"+files[0]+"/download", nil), 200)
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("HEAD consumed count")
}
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
path := objectPath(oid)
raw, _ := c.a.root.ReadFile(path)
raw[len(raw)-1] ^= 1
c.a.root.WriteFile(path, raw, 0600)
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 500)
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("failed empty-content authentication consumed count")
}
}
func TestSlowUploadIdleDeadline(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"slow"}, [][]byte{[]byte("0123456789")}, 2, false)
c.a.cfg.TransferIdleSeconds = 1
c.a.cfg.MaxTransferSeconds = 10
srv := httptest.NewServer(c.h)
defer srv.Close()
c.a.cfg.PublicURL = srv.URL
conn, e := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
if e != nil {
t.Fatal(e)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
_, e = fmt.Fprintf(conn, "PUT /api/v1/uploads/%s/files/%s HTTP/1.1\r\nHost: %s\r\nOrigin: %s\r\nCookie: bb_visitor=%s\r\nContent-Length: 10\r\nConnection: close\r\n\r\nx", id, files[0], strings.TrimPrefix(srv.URL, "http://"), srv.URL, c.cookies["bb_visitor"].Value)
if e != nil {
t.Fatal(e)
}
start := time.Now()
response, e := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "PUT"})
if e != nil {
t.Fatal(e)
}
defer response.Body.Close()
io.Copy(io.Discard, response.Body)
if response.StatusCode != 400 || time.Since(start) > 4*time.Second {
t.Fatal("idle upload was not rejected promptly")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("slow partial content became available")
}
}
func TestTrustedProxyChainAndIPv6(t *testing.T) {
c := fixture(t, false)
_, network, _ := net.ParseCIDR("10.0.0.0/8")
c.a.proxies = []*net.IPNet{network}
r := httptest.NewRequest("GET", "http://localhost", nil)
r.RemoteAddr = "10.0.0.1:80"
r.Header.Set("X-Forwarded-For", "8.8.8.8, 203.0.113.9, 10.0.0.2")
if c.a.remoteIP(r) != "203.0.113.9" {
t.Fatal("trusted proxy chain accepted leftmost forged IP")
}
r.RemoteAddr = "[2001:db8:1:2::1]:80"
r.Header.Del("X-Forwarded-For")
first := c.a.remoteIP(r)
r.RemoteAddr = "[2001:db8:1:2::ffff]:80"
if c.a.remoteIP(r) != first {
t.Fatal("IPv6 rotation evaded prefix grouping")
}
}
func testRuntimeBudgets(t *testing.T, a *App, file, data int64) {
t.Helper()
s, e := a.runtimeSettings()
if e != nil {
t.Fatal(e)
}
s.FileBudget = file
s.DataBudget = data
b, _ := json.Marshal(s)
if _, e = a.db.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b)); e != nil {
t.Fatal(e)
}
}
+211
View File
@@ -0,0 +1,211 @@
package app
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Backup is an offline operation: Open has already acquired the exclusive service lock.
func (a *App) Backup(configPath, target string) error {
abs, e := filepath.Abs(target)
if e != nil {
return e
}
data, e := filepath.Abs(a.cfg.DataDir)
if e != nil {
return e
}
if abs == data || strings.HasPrefix(abs, data+string(os.PathSeparator)) {
return errors.New("备份目标必须在数据目录之外")
}
if e = a.db.Close(); e != nil {
return e
}
a.db = nil
out, e := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
return e
}
success := false
defer func() {
out.Close()
if !success {
os.Remove(abs)
}
}()
gz := gzip.NewWriter(out)
tw := tar.NewWriter(gz)
write := func(name string, f *os.File, st os.FileInfo) error {
h := &tar.Header{Name: name, Mode: 0600, Size: st.Size(), ModTime: st.ModTime(), Typeflag: tar.TypeReg}
if e := tw.WriteHeader(h); e != nil {
return e
}
_, e := io.Copy(tw, f)
return e
}
cf, e := os.Open(configPath)
if e != nil {
return e
}
st, e := cf.Stat()
if e == nil {
e = write("config.toml", cf, st)
}
cf.Close()
if e != nil {
return e
}
e = fs.WalkDir(a.root.FS(), ".", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("拒绝备份符号链接: %s", path)
}
if d.IsDir() || path == "service.lock" {
return nil
}
if strings.HasPrefix(path, "tmp/") {
return nil
}
st, e := d.Info()
if e != nil {
return e
}
if !st.Mode().IsRegular() {
return errors.New("数据目录包含特殊文件")
}
f, e := a.root.Open(path)
if e != nil {
return e
}
defer f.Close()
return write("var/"+path, f, st)
})
if e != nil {
return e
}
if e = tw.Close(); e != nil {
return e
}
if e = gz.Close(); e != nil {
return e
}
if e = out.Sync(); e != nil {
return e
}
success = true
return nil
}
func Restore(c Config, source string) error {
if e := c.Validate(); e != nil {
return e
}
if e := os.MkdirAll(c.DataDir, 0700); e != nil {
return e
}
entries, e := os.ReadDir(c.DataDir)
if e != nil {
return e
}
if len(entries) != 0 {
return errors.New("恢复数据目录必须为空")
}
root, e := os.OpenRoot(c.DataDir)
if e != nil {
return e
}
defer root.Close()
if e = root.WriteFile("restore.incomplete", []byte("restoring"), 0600); e != nil {
return e
}
input, e := os.Open(source)
if e != nil {
return e
}
defer input.Close()
gz, e := gzip.NewReader(input)
if e != nil {
return e
}
defer gz.Close()
tr := tar.NewReader(gz)
var total int64
var count int
for {
h, e := tr.Next()
if errors.Is(e, io.EOF) {
break
}
if e != nil {
return e
}
if h.Typeflag != tar.TypeReg || !safeRelative(h.Name) {
return errors.New("备份包含非法路径或文件类型")
}
count++
if count > c.MaxRecords+32 {
return errors.New("备份文件数量超出恢复限制")
}
name := ""
if h.Name == "config.toml" {
name = "restored-config.toml"
} else if strings.HasPrefix(h.Name, "var/") {
name = strings.TrimPrefix(h.Name, "var/")
} else {
return errors.New("未知备份路径")
}
if !safeRelative(name) || h.Size < 0 || h.Size > c.DataBudget || total > c.DataBudget-h.Size {
return errors.New("备份超过恢复容量")
}
total += h.Size
if e = root.MkdirAll(filepath.Dir(name), 0700); e != nil {
return e
}
f, e := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
return e
}
_, e = io.CopyN(f, tr, h.Size)
if e == nil {
e = f.Sync()
}
ce := f.Close()
if e == nil {
e = ce
}
if e != nil {
return e
}
}
if _, e = io.Copy(io.Discard, gz); e != nil {
return e
}
// Invalidate before Open runs lifecycle cleanup, leaving payloads retained for admin review.
dbPath := filepath.Join(c.DataDir, "db", "bluebird.db")
if _, e = os.Stat(dbPath); e != nil {
return errors.New("备份缺少数据库")
}
a, e := OpenForRestore(c)
if e != nil {
return e
}
defer a.Close()
if e = a.Check(); e != nil {
return e
}
if e = a.InvalidateRestored(); e != nil {
return e
}
if e = root.Remove("restore.incomplete"); e != nil {
return e
}
return syncDir(root, ".")
}
+157
View File
@@ -0,0 +1,157 @@
package app
import (
"bytes"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"github.com/pelletier/go-toml/v2"
)
type Policy struct {
AnonymousUpload bool `json:"anonymous_upload" toml:"anonymous_upload"`
AnonymousDownload bool `json:"anonymous_download" toml:"anonymous_download"`
MaxFileBytes int64 `json:"max_file_bytes" toml:"max_file_bytes"`
MaxBatchBytes int64 `json:"max_batch_bytes" toml:"max_batch_bytes"`
MaxFiles int `json:"max_files" toml:"max_files"`
DefaultDays int `json:"default_days" toml:"default_days"`
MaxInitialDays int `json:"max_initial_days" toml:"max_initial_days"`
MaxLifetimeDays int `json:"max_lifetime_days" toml:"max_lifetime_days"`
DefaultDownloads int `json:"default_downloads" toml:"default_downloads"`
MaxDownloads int `json:"max_downloads" toml:"max_downloads"`
RenewalDays int `json:"renewal_days" toml:"renewal_days"`
RenewalMode string `json:"renewal_mode" toml:"renewal_mode"`
IPDailyBytes int64 `json:"ip_daily_bytes" toml:"ip_daily_bytes"`
IPDailyUploads int `json:"ip_daily_uploads" toml:"ip_daily_uploads"`
SessionDailyUploads int `json:"session_daily_uploads" toml:"session_daily_uploads"`
RequirePassword bool `json:"require_password" toml:"require_password"`
}
type Config struct {
Listen string `toml:"listen"`
PublicURL string `toml:"public_url"`
AccessURLs []string `toml:"access_urls"`
DataDir string `toml:"data_dir"`
SecureCookies bool `toml:"secure_cookies"`
AllowLANHTTP bool `toml:"allow_lan_http"`
Encryption bool `toml:"encryption"`
Deduplication bool `toml:"deduplication"`
TrustedProxies []string `toml:"trusted_proxies"`
DataBudget int64 `toml:"data_budget"`
FileBudget int64 `toml:"file_budget"`
MinFreeBytes int64 `toml:"min_free_bytes"`
AuditRetentionDays int `toml:"audit_retention_days"`
AuditMaxRecords int `toml:"audit_max_records"`
AuditMaxBytes int64 `toml:"audit_max_bytes"`
MaxUploads int `toml:"max_uploads"`
MaxDownloads int `toml:"max_downloads"`
MaxIPUploads int `toml:"max_ip_uploads"`
MaxIPDownloads int `toml:"max_ip_downloads"`
MaxRecords int `toml:"max_records"`
TransferIdleSeconds int `toml:"transfer_idle_seconds"`
MaxTransferSeconds int `toml:"max_transfer_seconds"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
Policy Policy `toml:"policy"`
}
func DefaultConfig() Config {
return Config{
Listen: "127.0.0.1:8080", PublicURL: "http://localhost:8080", DataDir: "var", Deduplication: true,
DataBudget: 10 << 30, FileBudget: 9 << 30, MinFreeBytes: 1 << 30, MaxUploads: 8, MaxDownloads: 16, MaxIPUploads: 2, MaxIPDownloads: 4, MaxRecords: 200000, MaxTransferSeconds: 3600, TransferIdleSeconds: 30,
AuditRetentionDays: 30, AuditMaxRecords: 100000, AuditMaxBytes: 32 << 20,
Policy: Policy{AnonymousUpload: true, AnonymousDownload: true, MaxFileBytes: 100 << 20, MaxBatchBytes: 500 << 20, MaxFiles: 10, DefaultDays: 1, MaxInitialDays: 7, MaxLifetimeDays: 30, DefaultDownloads: 5, MaxDownloads: 20, RenewalDays: 3, RenewalMode: "add", IPDailyBytes: 1 << 30, IPDailyUploads: 20, SessionDailyUploads: 20},
}
}
func (p Policy) Validate() error {
if p.MaxBatchBytes > 1<<40 || p.IPDailyBytes > 1<<50 || p.MaxFileBytes <= 0 || p.MaxBatchBytes <= 0 || p.MaxFiles < 1 || p.MaxFiles > 1000 || p.MaxFileBytes > p.MaxBatchBytes || p.DefaultDays < 1 || p.DefaultDays > p.MaxInitialDays || p.MaxInitialDays > p.MaxLifetimeDays || p.MaxLifetimeDays > 3650 || p.DefaultDownloads < 1 || p.DefaultDownloads > p.MaxDownloads || p.MaxDownloads > 100000 || p.RenewalDays < 0 || p.RenewalDays > p.MaxLifetimeDays || p.IPDailyBytes < 1 || p.IPDailyUploads < 1 || p.SessionDailyUploads < 1 {
return errors.New("策略参数范围错误")
}
if p.RenewalMode != "add" && p.RenewalMode != "sliding" {
return errors.New("renewal_mode 必须为 add 或 sliding")
}
return nil
}
func LoadConfig(path string) (Config, error) {
c := DefaultConfig()
b, e := os.ReadFile(path)
if e != nil {
return c, e
}
if e = toml.NewDecoder(bytes.NewReader(b)).DisallowUnknownFields().Decode(&c); e != nil {
return c, e
}
base, e := filepath.Abs(filepath.Dir(path))
if e != nil {
return c, e
}
if !filepath.IsAbs(c.DataDir) {
c.DataDir = filepath.Join(base, c.DataDir)
}
for _, ptr := range []*string{&c.TLSCert, &c.TLSKey} {
if *ptr != "" && !filepath.IsAbs(*ptr) {
*ptr = filepath.Join(base, *ptr)
}
}
return c, c.Validate()
}
func (c Config) Validate() error {
if e := c.InitialRuntimeSettings().Validate(); e != nil {
return e
}
if e := c.Policy.Validate(); e != nil {
return e
}
primary, e := normalizeAccessURL(c.PublicURL)
if e != nil {
return e
}
u, _ := url.Parse(primary)
if len(c.AccessURLs) > 16 {
return errors.New("额外访问地址最多16个")
}
for _, raw := range append([]string{c.PublicURL}, c.AccessURLs...) {
base, err := normalizeAccessURL(raw)
if err != nil {
return err
}
entry, _ := url.Parse(base)
ip := net.ParseIP(entry.Hostname())
if entry.Scheme == "http" && ip != nil && ip.IsPrivate() && !ip.IsLoopback() && !c.AllowLANHTTP {
return errors.New("内网HTTP入口须显式开启 allow_lan_http")
}
}
if u.Scheme == "https" && !c.SecureCookies {
return errors.New("HTTPS主入口须设置 secure_cookies = true")
}
if u.Scheme == "http" && c.SecureCookies {
return errors.New("HTTP主入口须设置 secure_cookies = false;额外HTTPS入口自动使用安全cookie")
}
listenHost, port, err := net.SplitHostPort(c.Listen)
if err != nil || port == "" {
return errors.New("listen须包含地址和端口")
}
// Preserve explicit-address protection for a single local/LAN HTTP site.
ip := net.ParseIP(u.Hostname())
if u.Scheme == "http" && (u.Hostname() == "localhost" || ip != nil && (ip.IsPrivate() || ip.IsLoopback())) {
bound := net.ParseIP(listenHost)
if listenHost != "localhost" && (bound == nil || !bound.IsPrivate() && !bound.IsLoopback()) {
return errors.New("内网HTTP须监听具体私有或回环地址")
}
}
if (c.TLSCert == "") != (c.TLSKey == "") {
return errors.New("TLS 证书和私钥需一起配置")
}
if c.FileBudget <= 0 || c.DataBudget-c.FileBudget < 64<<20 || c.MinFreeBytes < 0 || c.MaxUploads < 1 || c.MaxDownloads < 1 || c.MaxIPUploads < 1 || c.MaxIPDownloads < 1 || c.MaxRecords < 1 || c.MaxTransferSeconds < 1 || c.TransferIdleSeconds < 1 {
return fmt.Errorf("容量、并发或传输配置非法")
}
for _, p := range c.TrustedProxies {
if _, _, e := net.ParseCIDR(p); e != nil {
return fmt.Errorf("非法代理网段: %s", p)
}
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestConfigurationSafety(t *testing.T) {
c := DefaultConfig()
c.Listen = "0.0.0.0:8080"
if c.Validate() == nil {
t.Fatal("plain HTTP public listener accepted")
}
dir := t.TempDir()
p := filepath.Join(dir, "config.toml")
os.WriteFile(p, []byte("unknown_setting = true\n"), 0600)
if _, e := LoadConfig(p); e == nil {
t.Fatal("unknown configuration silently accepted")
}
os.WriteFile(p, []byte("data_dir = 'var'\n"), 0600)
loaded, e := LoadConfig(p)
if e != nil {
t.Fatal(e)
}
if loaded.DataDir != filepath.Join(dir, "var") {
t.Fatal("relative directory not resolved from configuration")
}
}
func TestLANHTTPRequiresExplicitPrivateAddress(t *testing.T) {
for _, test := range []struct {
name, listen, publicURL string
allow, valid bool
}{
{"disabled", "192.168.1.204:8080", "http://192.168.1.204:8080", false, false},
{"explicit LAN", "192.168.1.204:8080", "http://192.168.1.204:8080", true, true},
{"wildcard IPv4", "0.0.0.0:8080", "http://192.168.1.204:8080", true, false},
{"public listener", "8.8.8.8:8080", "http://192.168.1.204:8080", true, false},
{"public HTTP URL", "192.168.1.204:8080", "http://8.8.8.8:8080", true, true},
{"private IPv6", "[fd00::204]:8080", "http://[fd00::204]:8080", true, true},
{"wildcard IPv6", "[::]:8080", "http://[fd00::204]:8080", true, false},
{"loopback", "127.0.0.1:8080", "http://127.0.0.1:8080", false, true},
} {
t.Run(test.name, func(t *testing.T) {
c := DefaultConfig()
c.Listen, c.PublicURL, c.AllowLANHTTP = test.listen, test.publicURL, test.allow
if err := c.Validate(); (err == nil) != test.valid {
t.Fatalf("valid=%v, expected %v; error=%v", err == nil, test.valid, err)
}
})
}
c := DefaultConfig()
c.Listen, c.PublicURL, c.AllowLANHTTP, c.SecureCookies = "192.168.1.204:8080", "http://192.168.1.204:8080", true, true
if c.Validate() == nil {
t.Fatal("Secure cookies accepted for LAN HTTP")
}
}
+381
View File
@@ -0,0 +1,381 @@
package app
import (
"database/sql"
"errors"
"io"
"mime"
"net/http"
"strconv"
"time"
"filippo.io/age"
)
func (a *App) resolve(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "pickup", 10) {
fail(w, 429, "取件尝试过于频繁")
return
}
var in struct {
Code string `json:"code"`
Password string `json:"password"`
}
if !decode(w, r, &in) {
return
}
code := normalizeCode(in.Code)
if len(code) != 10 {
fail(w, 404, "取件信息无效或已失效")
return
}
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
if !p.AnonymousDownload {
fail(w, 403, "管理员已关闭取件")
return
}
var id, hash string
var version int
now := a.now().Unix()
e = a.db.QueryRow("SELECT id,password_hash,version FROM parcels WHERE code_hash=? AND state='ready' AND EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0 AND expires>?)", a.keyed(code), now).Scan(&id, &hash, &version)
if e != nil {
if !errors.Is(e, sql.ErrNoRows) {
a.serverError(w, e)
return
}
fail(w, 404, "取件信息无效或已失效")
return
}
if hash != "" {
if in.Password == "" {
respond(w, 401, map[string]any{"error": "请输入取件密码", "password_required": true})
return
}
select {
case a.passwordSlots <- struct{}{}:
default:
fail(w, 429, "密码验证繁忙")
return
}
valid := verifyPassword(in.Password, hash)
<-a.passwordSlots
if !valid {
fail(w, 404, "取件信息无效或已失效")
return
}
}
a.mu.Lock()
defer a.mu.Unlock()
var current int
e = a.db.QueryRow("SELECT version FROM parcels WHERE id=? AND state='ready' AND code_hash=?", id, a.keyed(code)).Scan(&current)
if e != nil || current != version {
fail(w, 404, "取件信息无效或已失效")
return
}
var count int
if e = a.db.QueryRow("SELECT count(*) FROM grants").Scan(&count); e != nil {
a.serverError(w, e)
return
}
if count >= a.cfg.MaxRecords {
fail(w, 429, "取件会话繁忙")
return
}
token := randomToken()
if _, e = a.db.Exec("INSERT INTO grants VALUES(?,?,?,?)", tokenHash(token), id, version, now+600); e != nil {
a.serverError(w, e)
return
}
a.cookie(w, r, "bb_pickup", token, "/api/v1/pickup", 600)
a.pickup(w, r.Clone(r.Context()), id)
}
// Explicit parcel IDs are only used immediately after a validated code exchange.
func (a *App) grant(r *http.Request) (string, error) {
var id string
e := a.db.QueryRow("SELECT p.id FROM grants g JOIN parcels p ON p.id=g.parcel WHERE g.token_hash=? AND g.expires>? AND p.state='ready' AND p.version=g.version", tokenHash(cookieValue(r, "bb_pickup")), a.now().Unix()).Scan(&id)
return id, e
}
func (a *App) fileList(id string) ([]map[string]any, error) {
rows, e := a.db.Query("SELECT id,name,size,status,remaining,expires FROM files WHERE parcel=? ORDER BY rowid", id)
if e != nil {
return nil, e
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var fid, name, status string
var size, expires int64
var remaining int
if e = rows.Scan(&fid, &name, &size, &status, &remaining, &expires); e != nil {
return nil, e
}
out = append(out, map[string]any{"id": fid, "name": name, "size": size, "status": status, "remaining": remaining, "expires": expires, "available": status == "ready" && remaining > 0 && expires > a.now().Unix()})
}
return out, rows.Err()
}
func (a *App) pickup(w http.ResponseWriter, r *http.Request, explicit ...string) {
id := ""
if len(explicit) > 0 {
id = explicit[0]
} else {
var e error
id, e = a.grant(r)
if e != nil {
fail(w, 401, "取件授权已失效,请重新输入取件码")
return
}
}
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
if !p.AnonymousDownload {
fail(w, 403, "管理员已关闭取件")
return
}
files, e := a.fileList(id)
if e != nil {
a.serverError(w, e)
return
}
var renewal int
var mode string
if e = a.db.QueryRow("SELECT renewal_days,renewal_mode FROM parcels WHERE id=?", id).Scan(&renewal, &mode); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, map[string]any{"id": id, "files": files, "renewal_days": renewal, "renewal_mode": mode})
}
func (a *App) pickupHandler(w http.ResponseWriter, r *http.Request) { a.pickup(w, r) }
type startWriter struct {
dst http.ResponseWriter
start func() error
started bool
bytes int64
controller *http.ResponseController
total time.Time
idle time.Duration
}
func (w *startWriter) Write(b []byte) (int, error) {
if !w.started {
if e := w.start(); e != nil {
return 0, e
}
w.started = true
}
if w.controller != nil {
end := time.Now().Add(w.idle)
if end.After(w.total) {
end = w.total
}
_ = w.controller.SetWriteDeadline(end)
}
n, e := w.dst.Write(b)
w.bytes += int64(n)
return n, e
}
func (a *App) download(w http.ResponseWriter, r *http.Request) {
ip := a.remoteIP(r)
if !a.acquire("download", ip) {
fail(w, 429, "下载并发已满")
return
}
defer a.release("download", ip)
a.deadlines(w, r)
a.mu.Lock()
parcel, e := a.grant(r)
if e != nil {
a.mu.Unlock()
fail(w, 401, "取件授权失效")
return
}
policy, e := a.policy()
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
if !policy.AnonymousDownload {
a.mu.Unlock()
fail(w, 403, "取件已关闭")
return
}
fid := r.PathValue("file")
var oid, name, status string
var size, expiry int64
var remaining int
var encrypted bool
e = a.db.QueryRow("SELECT f.object_id,f.name,f.size,f.status,f.remaining,f.expires,o.encrypted FROM files f JOIN objects o ON o.id=f.object_id WHERE f.id=? AND f.parcel=?", fid, parcel).Scan(&oid, &name, &size, &status, &remaining, &expiry, &encrypted)
if e != nil || status != "ready" || remaining < 1 || expiry <= a.now().Unix() {
a.mu.Unlock()
fail(w, 404, "文件已失效")
return
}
file, e := a.root.Open(objectPath(oid))
if e != nil {
a.mu.Unlock()
fail(w, 404, "文件不可用")
return
}
a.activeObjects[oid]++
defer func() {
a.mu.Lock()
_ = file.Close()
a.activeObjects[oid]--
if a.activeObjects[oid] == 0 {
delete(a.activeObjects, oid)
}
a.mu.Unlock()
}()
var reader io.Reader = file
if encrypted {
if a.identity == nil {
a.mu.Unlock()
a.serverError(w, errors.New("缺少私钥"))
return
}
reader, e = age.Decrypt(file, a.identity)
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
}
if r.Method == "HEAD" {
a.mu.Unlock()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(200)
return
}
attempt := randomID()
tx, e := a.db.Begin()
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
result, e := tx.Exec("UPDATE files SET remaining=remaining-1 WHERE id=? AND remaining>0 AND status='ready' AND expires>?", fid, a.now().Unix())
if e == nil {
n, _ := result.RowsAffected()
if n != 1 {
e = errors.New("次数或期限不足")
}
}
if e == nil {
_, e = tx.Exec("INSERT INTO downloads(id,file_id,created) VALUES(?,?,?)", attempt, fid, a.now().Unix())
}
if e == nil {
e = tx.Commit()
} else {
tx.Rollback()
}
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
a.mu.Unlock()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("Accept-Ranges", "none")
writer := &startWriter{dst: w, controller: http.NewResponseController(w), total: time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second), idle: time.Duration(a.cfg.TransferIdleSeconds) * time.Second, start: func() error { _, e := a.db.Exec("UPDATE downloads SET started=1 WHERE id=?", attempt); return e }}
// A zero-byte download is still an accepted download and must consume a count.
if size == 0 {
var b [1]byte
n, re := reader.Read(b[:])
if n != 0 || re != io.EOF {
e = errors.New("空文件内容异常")
}
if e == nil {
e = writer.start()
}
if e == nil {
writer.started = true
w.WriteHeader(200)
}
} else {
_, e = io.CopyBuffer(writer, io.LimitReader(reader, size+1), make([]byte, 128<<10))
}
a.mu.Lock()
defer a.mu.Unlock()
if !writer.started {
a.refund(attempt, fid)
w.Header().Del("Content-Length")
w.Header().Del("Content-Disposition")
a.serverError(w, errors.New("下载开始前失败"))
return
}
if e == nil && writer.bytes == size {
if e = a.completeDownload(attempt, fid, parcel); e != nil {
a.log.Error("download completion failed", "error", e)
}
} else {
a.log.Warn("download interrupted", "file", fid, "bytes", writer.bytes)
}
}
func (a *App) refund(attempt, fid string) {
tx, e := a.db.Begin()
if e != nil {
return
}
defer tx.Rollback()
res, e := tx.Exec("UPDATE downloads SET refunded=1 WHERE id=? AND started=0 AND refunded=0", attempt)
if e != nil {
return
}
n, _ := res.RowsAffected()
if n == 1 {
_, e = tx.Exec("UPDATE files SET remaining=remaining+1 WHERE id=?", fid)
}
if e == nil {
_ = tx.Commit()
}
}
func (a *App) completeDownload(attempt, fid, parcel string) error {
tx, e := a.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
res, e := tx.Exec("UPDATE downloads SET completed=1 WHERE id=? AND completed=0", attempt)
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 {
return tx.Commit()
}
var days, remaining int
var mode, state, status string
var expiry, hard int64
e = tx.QueryRow("SELECT p.renewal_days,p.renewal_mode,p.state,f.status,f.expires,f.hard_expires,f.remaining FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=?", fid).Scan(&days, &mode, &state, &status, &expiry, &hard, &remaining)
if e != nil {
return e
}
if state == "ready" && status == "ready" && remaining > 0 && days > 0 {
newExpiry := max(expiry, a.now().Unix()+int64(days)*86400)
if mode == "add" {
newExpiry = max(expiry, a.now().Unix()) + int64(days)*86400
}
newExpiry = min(newExpiry, hard)
if newExpiry > expiry {
if _, e = tx.Exec("UPDATE files SET expires=? WHERE id=?", newExpiry, fid); e != nil {
return e
}
if _, e = tx.Exec("UPDATE parcels SET expires=(SELECT max(expires) FROM files WHERE parcel=?) WHERE id=?", parcel, parcel); e != nil {
return e
}
}
}
return tx.Commit()
}
+71
View File
@@ -0,0 +1,71 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"strings"
"filippo.io/age"
)
func (a *App) verifyObjects() error {
rows, e := a.db.Query("SELECT id,digest,size,encrypted,key_id FROM objects")
if e != nil {
return e
}
type object struct {
id, digest, key string
size int64
encrypted bool
}
objects := []object{}
for rows.Next() {
var o object
if e = rows.Scan(&o.id, &o.digest, &o.size, &o.encrypted, &o.key); e != nil {
rows.Close()
return e
}
objects = append(objects, o)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, o := range objects {
if len(o.id) != 32 {
return errors.New("非法对象ID")
}
if _, e = hex.DecodeString(o.id); e != nil {
return e
}
f, e := a.root.Open(objectPath(o.id))
if e != nil {
return e
}
var reader io.Reader = f
if o.encrypted {
if a.identity == nil {
f.Close()
return errors.New("缺少加密私钥")
}
reader, e = age.Decrypt(f, a.identity)
if e != nil {
f.Close()
return e
}
}
h := sha256.New()
n, e := io.CopyBuffer(h, reader, make([]byte, 128<<10))
f.Close()
if e != nil {
return e
}
if n != o.size || a.keyed(hex.EncodeToString(h.Sum(nil))+"/"+o.key) != strings.Split(o.digest, "/")[0] {
return errors.New("对象内容完整性检查失败: " + o.id)
}
}
return nil
}
+337
View File
@@ -0,0 +1,337 @@
package app
import (
"encoding/hex"
"errors"
"io/fs"
"os"
"strings"
)
func (a *App) recover() error {
// No other process owns this data directory. Partial sessions are never published.
// An interrupted attempt may have transferred bytes not yet recorded; charge its full allocation conservatively.
if _, e := a.db.Exec("UPDATE files SET received=max(received,charged) WHERE status='pending' AND attempts>0 AND parcel IN(SELECT id FROM parcels WHERE state='uploading')"); e != nil {
return e
}
if _, e := a.db.Exec("UPDATE parcels SET state='revoked',version=version+1 WHERE state='uploading'"); e != nil {
return e
}
entries, e := fs.ReadDir(a.root.FS(), "tmp")
if e != nil {
return e
}
for _, entry := range entries {
if entry.Type()&os.ModeSymlink != 0 {
return errors.New("临时目录存在符号链接")
}
if e = a.root.RemoveAll("tmp/" + entry.Name()); e != nil {
return e
}
}
rows, e := a.db.Query("SELECT id FROM objects")
if e != nil {
return e
}
known := map[string]bool{}
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
known[id] = true
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for id := range known {
if len(id) != 32 {
return errors.New("非法对象标识")
}
if _, e := hex.DecodeString(id); e != nil {
return errors.New("非法对象标识")
}
st, e := a.root.Lstat(objectPath(id))
if e != nil || !st.Mode().IsRegular() {
if e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if _, e = a.db.Exec("UPDATE files SET status='missing' WHERE object_id=?", id); e != nil {
return e
}
}
}
e = fs.WalkDir(a.root.FS(), "objects", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return errors.New("对象目录存在符号链接")
}
if d.IsDir() {
return nil
}
if !known[d.Name()] {
return a.root.Rename(path, "quarantine/"+randomID())
}
return nil
})
if e != nil {
return e
}
return a.Cleanup()
}
func (a *App) settleUsage(parcel string) error {
var ip, visitor, day string
if e := a.db.QueryRow("SELECT ip,visitor,usage_day FROM parcels WHERE id=?", parcel).Scan(&ip, &visitor, &day); e != nil {
return e
}
var refund int64
if e := a.db.QueryRow("SELECT COALESCE(sum(max(charged-received,0)),0) FROM files WHERE parcel=?", parcel).Scan(&refund); e != nil {
return e
}
tx, e := a.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
for _, s := range []string{"ip:" + a.keyed(ip), "visitor:" + visitor} {
if _, e = tx.Exec("UPDATE usage SET bytes=max(0,bytes-?) WHERE subject=? AND day=?", refund, s, day); e != nil {
return e
}
}
if _, e = tx.Exec("UPDATE files SET charged=received WHERE parcel=?", parcel); e != nil {
return e
}
return tx.Commit()
}
func (a *App) Cleanup() error {
a.mu.Lock()
defer a.mu.Unlock()
now := a.now().Unix()
rows, e := a.db.Query("SELECT id,state,upload_expires FROM parcels WHERE state='uploading' OR state IN('revoked','expired','exhausted')")
if e != nil {
return e
}
type parcel struct {
id, state string
expiry int64
}
parcels := []parcel{}
for rows.Next() {
var p parcel
if e = rows.Scan(&p.id, &p.state, &p.expiry); e != nil {
rows.Close()
return e
}
parcels = append(parcels, p)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, p := range parcels {
if p.state == "uploading" && p.expiry > now {
continue
}
rows, e := a.db.Query("SELECT id FROM files WHERE parcel=?", p.id)
if e != nil {
return e
}
active := false
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
if a.activeFiles[id] {
active = true
}
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
if active {
continue
}
if p.state == "uploading" {
if _, e = a.db.Exec("UPDATE parcels SET state='expired',version=version+1 WHERE id=?", p.id); e != nil {
return e
}
}
if e = a.settleUsage(p.id); e != nil {
return e
}
if _, e = a.db.Exec("UPDATE files SET status='deleting' WHERE parcel=?", p.id); e != nil {
return e
}
}
rows, e = a.db.Query("SELECT f.id,COALESCE(f.object_id,''),f.status,f.expires,f.remaining,p.state FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.status!='deleted'")
if e != nil {
return e
}
type file struct {
id, object, status, state string
expiry int64
remaining int
}
files := []file{}
for rows.Next() {
var f file
if e = rows.Scan(&f.id, &f.object, &f.status, &f.expiry, &f.remaining, &f.state); e != nil {
rows.Close()
return e
}
files = append(files, f)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, f := range files {
if a.activeFiles[f.id] || a.activeObjects[f.object] > 0 {
continue
}
remove := f.status == "deleting" || f.status == "missing" || (f.state == "ready" && (f.remaining == 0 || f.expiry <= now))
if !remove {
continue
}
if e = a.root.Remove("tmp/" + f.id); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if _, e = a.db.Exec("UPDATE files SET status='deleted',reserved=0,object_id=NULL WHERE id=?", f.id); e != nil {
return e
}
}
// Objects remain charged until unlink succeeds. Sharing records never unlink each other's payloads.
rows, e = a.db.Query("SELECT id FROM objects WHERE NOT EXISTS(SELECT 1 FROM files WHERE object_id=objects.id)")
if e != nil {
return e
}
ids := []string{}
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
ids = append(ids, id)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, id := range ids {
if a.activeObjects[id] > 0 {
continue
}
if e = a.root.Remove(objectPath(id)); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if _, e = a.db.Exec("DELETE FROM objects WHERE id=?", id); e != nil {
return e
}
}
if _, e = a.db.Exec("UPDATE parcels SET state='exhausted',version=version+1 WHERE state='ready' AND NOT EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0)"); e != nil {
return e
}
for _, q := range []struct {
sql string
args []any
}{
{"DELETE FROM visitors WHERE expires<=?", []any{now}}, {"DELETE FROM grants WHERE expires<=?", []any{now}}, {"DELETE FROM admin_sessions WHERE expires<=?", []any{now}}, {"DELETE FROM usage WHERE day<?", []any{a.now().UTC().AddDate(0, 0, -8).Format("2006-01-02")}}, {"DELETE FROM downloads WHERE created<?", []any{now - 30*86400}}, {"DELETE FROM parcels WHERE state IN('revoked','expired','exhausted') AND created<? AND NOT EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status!='deleted')", []any{now - 30*86400}},
} {
if _, e = a.db.Exec(q.sql, q.args...); e != nil {
return e
}
}
if e = a.trimAudit(); e != nil {
return e
}
entries, e := fs.ReadDir(a.root.FS(), "quarantine")
if e != nil {
return e
}
for _, d := range entries {
st, e := d.Info()
if e != nil {
return e
}
if now-st.ModTime().Unix() > 86400 {
if e = a.root.Remove("quarantine/" + d.Name()); e != nil {
return e
}
}
}
if e = a.scanOrphans(); e != nil {
return e
}
a.extraDisk = 0
e = fs.WalkDir(a.root.FS(), "quarantine", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
st, e := d.Info()
if e == nil {
a.extraDisk += allocated(st)
}
return e
})
if e != nil {
return e
}
_, e = a.db.Exec("PRAGMA wal_checkpoint(PASSIVE)")
return e
}
func (a *App) InvalidateRestored() error {
_, e := a.db.Exec("UPDATE parcels SET state='restored',code_hash=NULL,manage_hash=lower(hex(randomblob(32))),version=version+1; DELETE FROM grants; DELETE FROM admin_sessions; DELETE FROM visitors;")
return e
}
func safeRelative(s string) bool {
return s != "" && !strings.HasPrefix(s, "/") && !strings.Contains(s, "\\") && fs.ValidPath(s)
}
func (a *App) scanOrphans() error {
rows, e := a.db.Query("SELECT id FROM objects")
if e != nil {
return e
}
known := map[string]bool{}
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
known[id] = true
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
return fs.WalkDir(a.root.FS(), "objects", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return errors.New("对象目录存在符号链接")
}
if d.IsDir() {
return nil
}
if !known[d.Name()] {
return a.root.Rename(path, "quarantine/"+randomID())
}
return nil
})
}
+143
View File
@@ -0,0 +1,143 @@
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()
}
+177
View File
@@ -0,0 +1,177 @@
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func loginTestAdmin(t *testing.T, c *testClient) {
t.Helper()
expect(t, c.json(t, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}), 200)
}
func TestAdminRuntimeBudgetsPersistAndProtectExistingFiles(t *testing.T) {
c := fixture(t, false)
expect(t, c.request(t, "GET", "/admin/runtime", nil), 401)
data := []byte("preserved file")
id, files := c.create(t, []string{"a"}, [][]byte{data}, 3, false)
c.upload(t, id, files[0], data)
code := c.publish(t, id)
loginTestAdmin(t, c)
settings, e := c.a.runtimeSettings()
if e != nil {
t.Fatal(e)
}
settings.FileBudget = 1
expect(t, c.json(t, "PUT", "/admin/runtime", settings), 200)
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "new", Size: 1}}}), 507)
c.resolve(t, code)
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 200)
cfg := c.a.cfg
c.a.Close()
c.a.db = nil
c.a.root = nil
c.a.lock = nil
a, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
defer a.Close()
saved, e := a.runtimeSettings()
if e != nil || saved.FileBudget != 1 {
t.Fatal("runtime budget reverted on restart")
}
if scalar(t, a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("reducing quota deleted existing files")
}
}
func TestAuditRetentionRecordsAndUTF8ByteBudget(t *testing.T) {
c := fixture(t, false)
settings, e := c.a.runtimeSettings()
if e != nil {
t.Fatal(e)
}
settings.AuditRetentionDays = 2
settings.AuditMaxRecords = 3
settings.AuditMaxBytes = 1024
b, _ := json.Marshal(settings)
c.a.db.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b))
_, e = c.a.db.Exec("INSERT INTO audit(created,action,target,source) VALUES(?,?,?,?)", c.a.now().Unix()-3*86400, "old", "", "")
if e != nil {
t.Fatal(e)
}
for i := 0; i < 8; i++ {
c.a.audit("new", "target", "203.0.113.1")
}
if scalar(t, c.a, "SELECT count(*) FROM audit") != 3 || scalar(t, c.a, "SELECT count(*) FROM audit WHERE action='old'") != 0 {
t.Fatal("retention/count limits failed")
}
settings.AuditMaxRecords = 100
b, _ = json.Marshal(settings)
c.a.db.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b))
for i := 0; i < 10; i++ {
c.a.audit("utf8", strings.Repeat("鸟", 60), "source")
}
actual := scalar(t, c.a, "SELECT COALESCE(sum("+auditRecordBytes+"),0) FROM audit")
ledger := scalar(t, c.a, "SELECT content_bytes FROM audit_usage WHERE id=1")
if actual != ledger || actual > 1024 {
t.Fatalf("audit bytes actual=%d ledger=%d", actual, ledger)
}
if scalar(t, c.a, "SELECT records FROM audit_usage WHERE id=1") != scalar(t, c.a, "SELECT count(*) FROM audit") {
t.Fatal("audit count ledger incorrect")
}
_, e = c.a.db.Exec("UPDATE audit SET created=?", c.a.now().Unix()-3*86400)
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM audit") != 0 {
t.Fatal("background cleanup did not age audit events")
}
}
func TestAnonymousIdentityAndReceiptHaveIndependentLifetimes(t *testing.T) {
c := fixture(t, false)
base := time.Now()
c.a.now = func() time.Time { return base }
var info struct {
Expires int64 `json:"visitor_expires"`
}
w := c.request(t, "GET", "/policy", nil)
expect(t, w, 200)
json.Unmarshal(w.Body.Bytes(), &info)
if info.Expires != base.Unix()+7*86400 {
t.Fatal("cookie lifetime not seven days")
}
w = c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "a", Size: 3}}, Days: 7, Downloads: 5, Renew: true})
expect(t, w, 201)
var receipt struct {
ID string `json:"id"`
Token string `json:"manage_token"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
json.Unmarshal(w.Body.Bytes(), &receipt)
c.upload(t, receipt.ID, receipt.Files[0].ID, []byte("abc"))
code := c.publish(t, receipt.ID)
c.a.now = func() time.Time { return base.Add(6 * 24 * time.Hour) }
w = c.request(t, "GET", "/policy", nil)
json.Unmarshal(w.Body.Bytes(), &info)
if info.Expires != base.Unix()+7*86400 {
t.Fatal("fixed visitor lifetime unexpectedly extended")
}
c.resolve(t, code)
expect(t, c.request(t, "GET", "/pickup/files/"+receipt.Files[0].ID+"/download", nil), 200)
c.a.now = func() time.Time { return base.Add(8 * 24 * time.Hour) }
expect(t, c.request(t, "GET", "/policy", nil), 200)
w = c.request(t, "GET", "/mine", nil)
expect(t, w, 200)
if strings.TrimSpace(w.Body.String()) != "[]" {
t.Fatal("expired browser identity still owns old list")
}
r := c.makeReceiptRequest("GET", "/uploads/"+receipt.ID, receipt.Token, nil)
response := serveTest(c, r)
expect(t, response, 200)
c.a.now = func() time.Time { return base.Add(31 * 24 * time.Hour) }
if e := c.a.Cleanup(); e != nil {
t.Fatal(e)
}
expect(t, serveTest(c, c.makeReceiptRequest("GET", "/uploads/"+receipt.ID, receipt.Token, nil)), 404)
}
func TestServiceSwitchesDoNotGiveAdminUploadOrPickupBypass(t *testing.T) {
c := fixture(t, false)
data := []byte("content")
id, files := c.create(t, []string{"a"}, [][]byte{data}, 3, false)
c.upload(t, id, files[0], data)
c.resolve(t, c.publish(t, id))
loginTestAdmin(t, c)
policy, e := c.a.policy()
if e != nil {
t.Fatal(e)
}
policy.AnonymousUpload = false
policy.AnonymousDownload = false
expect(t, c.json(t, "PUT", "/admin/policy", policy), 200)
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "a", Size: 1}}}), 403)
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 403)
expect(t, c.request(t, "GET", "/admin/stats", nil), 200)
}
func (c *testClient) makeReceiptRequest(method, path, token string, body []byte) *http.Request {
r := httptest.NewRequest(method, "http://localhost:8080/api/v1"+path, bytes.NewReader(body))
r.RemoteAddr = c.ip + ":80"
r.Header.Set("Origin", "http://localhost:8080")
r.Header.Set("Authorization", "Bearer "+token)
return r
}
func serveTest(c *testClient, r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
return w
}
+254
View File
@@ -0,0 +1,254 @@
package app
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/argon2"
"golang.org/x/time/rate"
)
func randomToken() string {
b := make([]byte, 32)
if _, e := rand.Read(b); e != nil {
panic(e)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func randomID() string {
b := make([]byte, 16)
if _, e := rand.Read(b); e != nil {
panic(e)
}
return hex.EncodeToString(b)
}
func tokenHash(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func (a *App) keyed(s string) string {
h := hmac.New(sha256.New, a.secret)
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}
func pickupCode() string {
b := make([]byte, 10)
if _, e := rand.Read(b); e != nil {
panic(e)
}
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
for i := range b {
b[i] = alphabet[b[i]&31]
}
return string(b)
}
func normalizeCode(s string) string {
s = strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(s), "-", ""))
s = strings.ReplaceAll(s, "O", "0")
s = strings.ReplaceAll(s, "I", "1")
return strings.ReplaceAll(s, "L", "1")
}
func hashPassword(p string) (string, error) {
if len(p) < 8 || len(p) > 128 {
return "", errors.New("密码需为 8–128 字节")
}
salt := make([]byte, 16)
if _, e := rand.Read(salt); e != nil {
return "", e
}
h := argon2.IDKey([]byte(p), salt, 2, 32*1024, 1, 32)
return fmt.Sprintf("v1$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(h)), nil
}
func verifyPassword(p, h string) bool {
parts := strings.Split(h, "$")
if len(p) > 128 || len(parts) != 3 || parts[0] != "v1" {
return false
}
salt, e := base64.RawStdEncoding.DecodeString(parts[1])
if e != nil || len(salt) != 16 {
return false
}
want, e := base64.RawStdEncoding.DecodeString(parts[2])
if e != nil || len(want) != 32 {
return false
}
got := argon2.IDKey([]byte(p), salt, 2, 32*1024, 1, 32)
return subtle.ConstantTimeCompare(want, got) == 1
}
func (a *App) remoteIP(r *http.Request) string {
host, _, e := net.SplitHostPort(r.RemoteAddr)
if e != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return "unknown"
}
trusted := func(v net.IP) bool {
for _, n := range a.proxies {
if n.Contains(v) {
return true
}
}
return false
}
if trusted(ip) {
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
if len(parts) <= 16 {
for i := len(parts) - 1; i >= 0; i-- {
candidate := net.ParseIP(strings.TrimSpace(parts[i]))
if candidate == nil {
break
}
ip = candidate
if !trusted(ip) {
break
}
}
}
}
if v := ip.To4(); v != nil {
return v.String()
}
return ip.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
func (a *App) cookie(w http.ResponseWriter, r *http.Request, name, value, path string, seconds int) {
state := a.accessFor(r)
if state.httpCookie {
name += "_http"
}
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: path, MaxAge: seconds, HttpOnly: true, Secure: strings.HasPrefix(state.base, "https://"), SameSite: http.SameSiteStrictMode})
}
func (a *App) visitor(w http.ResponseWriter, r *http.Request) (string, error) {
now := a.now().Unix()
if token := cookieValue(r, "bb_visitor"); token != "" && len(token) < 128 {
var id string
e := a.db.QueryRow("SELECT id FROM visitors WHERE token_hash=? AND expires>?", tokenHash(token), now).Scan(&id)
if e == nil {
return id, nil
}
}
var count int
if e := a.db.QueryRow("SELECT count(*) FROM visitors").Scan(&count); e != nil {
return "", e
}
if count >= a.cfg.MaxRecords {
return "", errors.New("匿名会话已满,请稍后重试")
}
id, t := randomID(), randomToken()
_, e := a.db.Exec("INSERT INTO visitors(id,token_hash,expires) VALUES(?,?,?)", id, tokenHash(t), now+7*86400)
if e == nil {
a.cookie(w, r, "bb_visitor", t, "/api/v1", 7*86400)
}
return id, e
}
func (a *App) currentVisitor(r *http.Request) string {
token := cookieValue(r, "bb_visitor")
if token == "" {
return ""
}
var id string
a.db.QueryRow("SELECT id FROM visitors WHERE token_hash=? AND expires>?", tokenHash(token), a.now().Unix()).Scan(&id)
return id
}
func (a *App) isAdmin(r *http.Request) bool {
token := cookieValue(r, "bb_admin")
if token == "" {
return false
}
var n int
e := a.db.QueryRow("SELECT count(*) FROM admin_sessions WHERE token_hash=? AND expires>?", tokenHash(token), a.now().Unix()).Scan(&n)
return e == nil && n == 1
}
type rateEntry struct {
limiter *rate.Limiter
touched time.Time
}
func (a *App) allow(r *http.Request, kind string, perMinute int) bool {
a.rateMu.Lock()
defer a.rateMu.Unlock()
now := a.now()
key := kind + ":" + a.remoteIP(r)
entry := a.rates[key]
if entry == nil {
if len(a.rates) >= 10000 {
for k, v := range a.rates {
if now.Sub(v.touched) > 10*time.Minute {
delete(a.rates, k)
}
}
if len(a.rates) >= 10000 {
return false
}
}
entry = &rateEntry{limiter: rate.NewLimiter(rate.Limit(float64(perMinute)/60), max(2, perMinute/3))}
a.rates[key] = entry
}
entry.touched = now
return entry.limiter.Allow()
}
func (a *App) acquire(kind, ip string) bool {
a.transferMu.Lock()
defer a.transferMu.Unlock()
limit, perIP := a.cfg.MaxUploads, a.cfg.MaxIPUploads
if kind == "download" {
limit, perIP = a.cfg.MaxDownloads, a.cfg.MaxIPDownloads
}
if a.transfers[kind] >= limit || a.transfers[kind+":"+ip] >= perIP {
return false
}
a.transfers[kind]++
a.transfers[kind+":"+ip]++
return true
}
func (a *App) release(kind, ip string) {
a.transferMu.Lock()
defer a.transferMu.Unlock()
a.transfers[kind]--
a.transfers[kind+":"+ip]--
if a.transfers[kind+":"+ip] == 0 {
delete(a.transfers, kind+":"+ip)
}
}
func (a *App) deadlines(w http.ResponseWriter, r *http.Request) {
c := http.NewResponseController(w)
end := time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second)
_ = c.SetReadDeadline(end)
_ = c.SetWriteDeadline(end)
}
func cookieValue(r *http.Request, name string) string {
for _, key := range cookieNames(r, name) {
if c, err := r.Cookie(key); err == nil && c.Value != "" {
return c.Value
}
}
return ""
}
func parseInt(s string) int { v, _ := strconv.Atoi(s); return v }
type idleReader struct {
reader io.Reader
controller *http.ResponseController
total time.Time
idle time.Duration
}
func (r *idleReader) Read(p []byte) (int, error) {
end := time.Now().Add(r.idle)
if end.After(r.total) {
end = r.total
}
_ = r.controller.SetReadDeadline(end)
return r.reader.Read(p)
}
+678
View File
@@ -0,0 +1,678 @@
package app
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unicode"
"filippo.io/age"
"golang.org/x/sys/unix"
)
type ManifestFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
type Manifest struct {
Files []ManifestFile `json:"files"`
Days int `json:"days"`
Downloads int `json:"downloads"`
Password string `json:"password"`
Renew bool `json:"renew"`
}
func reserveSize(n int64, encrypted bool) int64 {
if encrypted {
n += 1024 + ((n/65536)+1)*16
}
return ((n+4095)/4096)*4096 + 4096
}
func cleanName(s string) string {
s = strings.ReplaceAll(s, "\\", "/")
s = filepath.Base(s)
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
return strings.TrimSpace(s)
}
func objectPath(id string) string { return "objects/" + id[:2] + "/" + id }
func (a *App) capacity() (used, reserved, metadata int64, err error) {
err = a.db.QueryRow("SELECT COALESCE(sum(stored_bytes),0) FROM objects").Scan(&used)
if err != nil {
return
}
err = a.db.QueryRow("SELECT COALESCE(sum(reserved),0) FROM files WHERE object_id IS NULL AND reserved>0 AND status!='deleted'").Scan(&reserved)
if err != nil {
return
}
for _, p := range []string{"db/bluebird.db", "db/bluebird.db-wal", "db/bluebird.db-shm"} {
s, e := a.root.Stat(p)
if e == nil {
metadata += allocated(s)
} else if !errors.Is(e, os.ErrNotExist) {
err = e
return
}
}
metadata += a.extraDisk
return
}
func allocated(s os.FileInfo) int64 {
if st, ok := s.Sys().(*syscall.Stat_t); ok {
return max(st.Blocks*512, s.Size())
}
if st, ok := s.Sys().(*unix.Stat_t); ok {
return max(st.Blocks*512, s.Size())
}
return ((s.Size() + 4095) / 4096) * 4096
}
func (a *App) createUpload(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "upload", 20) {
fail(w, 429, "上传请求过于频繁")
return
}
var m Manifest
if !decode(w, r, &m) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
if !p.AnonymousUpload {
fail(w, 403, "管理员已关闭上传")
return
}
if m.Days == 0 {
m.Days = p.DefaultDays
}
if m.Downloads == 0 {
m.Downloads = p.DefaultDownloads
}
if m.Days < 1 || m.Days > p.MaxInitialDays || m.Downloads < 1 || m.Downloads > p.MaxDownloads || len(m.Files) < 1 || len(m.Files) > p.MaxFiles {
fail(w, 400, "文件数量、期限或下载次数超出策略")
return
}
if p.RequirePassword && m.Password == "" {
fail(w, 400, "当前策略要求取件密码")
return
}
hash := ""
if m.Password != "" {
if len(m.Password) < 8 || len(m.Password) > 128 {
fail(w, 400, "取件密码需为 8–128 字节")
return
}
select {
case a.passwordSlots <- struct{}{}:
default:
fail(w, 429, "密码验证繁忙")
return
}
hash, e = hashPassword(m.Password)
<-a.passwordSlots
if e != nil {
fail(w, 400, e.Error())
return
}
}
var total, reservation int64
for i, f := range m.Files {
m.Files[i].Name = cleanName(f.Name)
if m.Files[i].Name == "" || m.Files[i].Name == "." || m.Files[i].Name == ".." || len(m.Files[i].Name) > 240 || f.Size < 0 || f.Size > p.MaxFileBytes || total > p.MaxBatchBytes-f.Size {
fail(w, 400, "文件名、文件大小或批次总大小无效")
return
}
total += f.Size
reservation += reserveSize(f.Size, a.cfg.Encryption)
}
if total > p.MaxBatchBytes {
fail(w, 400, "批次总大小超出限制")
return
}
var count int
if e = a.db.QueryRow("SELECT count(*) FROM files").Scan(&count); e != nil {
a.serverError(w, e)
return
}
if count+len(m.Files) > a.cfg.MaxRecords {
fail(w, 507, "文件记录已满")
return
}
used, reserved, meta, e := a.capacity()
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
}
free := int64(disk.Bavail) * int64(disk.Bsize)
limits, e := a.runtimeSettings()
if e != nil {
a.serverError(w, e)
return
}
if used+reserved+reservation > limits.FileBudget || used+reserved+reservation+meta > limits.DataBudget || free-reserved-reservation < limits.MinFreeBytes || (disk.Files > 0 && disk.Ffree < uint64(len(m.Files)+32)) {
fail(w, 507, "可用存储不足,暂时无法接收文件")
return
}
visitor, e := a.visitor(w, r)
if e != nil {
fail(w, 429, "匿名会话暂不可用")
return
}
ip := a.remoteIP(r)
day := a.now().UTC().Format("2006-01-02")
subjects := []string{"ip:" + a.keyed(ip), "visitor:" + visitor}
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
for i, subject := range subjects {
if _, e = tx.Exec("INSERT OR IGNORE INTO usage VALUES(?,?,0,0)", subject, day); e != nil {
a.serverError(w, e)
return
}
var b int64
var n int
if e = tx.QueryRow("SELECT bytes,uploads FROM usage WHERE subject=? AND day=?", subject, day).Scan(&b, &n); e != nil {
a.serverError(w, e)
return
}
limit := p.IPDailyUploads
if i == 1 {
limit = p.SessionDailyUploads
}
if n >= limit || (i == 0 && b > p.IPDailyBytes-total) {
fail(w, 429, "今日上传次数或流量额度已用完")
return
}
if _, e = tx.Exec("UPDATE usage SET bytes=bytes+?,uploads=uploads+1 WHERE subject=? AND day=?", total, subject, day); e != nil {
a.serverError(w, e)
return
}
}
id, manage := randomID(), randomToken()
now := a.now().Unix()
renewal := 0
if m.Renew {
renewal = p.RenewalDays
}
_, e = tx.Exec("INSERT INTO parcels(id,visitor,ip,state,created,password_hash,manage_hash,days,downloads,renewal_days,renewal_mode,lifetime_days,upload_expires,usage_day) VALUES(?,?,?,'uploading',?,?,?,?,?,?,?,?,?,?)", id, visitor, ip, now, hash, tokenHash(manage), m.Days, m.Downloads, renewal, p.RenewalMode, p.MaxLifetimeDays, now+3600, day)
if e != nil {
a.serverError(w, e)
return
}
out := []map[string]any{}
for _, f := range m.Files {
fid := randomID()
if _, e = tx.Exec("INSERT INTO files(id,parcel,name,size,status,reserved,remaining,charged) VALUES(?,?,?,?,'pending',?,?,?)", fid, id, f.Name, f.Size, reserveSize(f.Size, a.cfg.Encryption), m.Downloads, f.Size); e != nil {
a.serverError(w, e)
return
}
out = append(out, map[string]any{"id": fid, "name": f.Name, "size": f.Size})
}
if e = tx.Commit(); e != nil {
a.serverError(w, e)
return
}
a.audit("upload.create", id, ip)
respond(w, 201, map[string]any{"id": id, "manage_token": manage, "files": out})
}
func (a *App) owner(r *http.Request, id string) bool {
var owner, manage string
if e := a.db.QueryRow("SELECT visitor,manage_hash FROM parcels WHERE id=?", id).Scan(&owner, &manage); e != nil {
return false
}
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token != "" && tokenHash(token) == manage {
return true
}
v := a.currentVisitor(r)
return v != "" && v == owner
}
func (a *App) uploadFile(w http.ResponseWriter, r *http.Request) {
id, fid := r.PathValue("parcel"), r.PathValue("file")
ip := a.remoteIP(r)
if !a.allow(r, "file-transfer", 120) {
fail(w, 429, "文件传输请求过于频繁")
return
}
if !a.acquire("upload", ip) {
fail(w, 429, "上传并发已满,请稍后重试")
return
}
defer a.release("upload", ip)
a.deadlines(w, r)
a.mu.Lock()
if !a.owner(r, id) {
a.mu.Unlock()
fail(w, 404, "上传会话不存在")
return
}
var state, status string
var expected, reserved, expiry int64
e := a.db.QueryRow("SELECT p.state,f.status,f.size,f.reserved,p.upload_expires FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=? AND p.id=?", fid, id).Scan(&state, &status, &expected, &reserved, &expiry)
if e != nil || state != "uploading" || expiry <= a.now().Unix() {
a.mu.Unlock()
fail(w, 409, "上传会话已失效")
return
}
if status == "ready" {
a.mu.Unlock()
respond(w, 200, map[string]bool{"ready": true})
return
}
if a.activeFiles[fid] {
a.mu.Unlock()
fail(w, 409, "该文件正在上传")
return
}
var attempts int
var sessionIP, visitor, day string
if e = a.db.QueryRow("SELECT f.attempts,p.ip,p.visitor,p.usage_day FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=?", fid).Scan(&attempts, &sessionIP, &visitor, &day); e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
policy, pe := a.policy()
if pe != nil {
a.mu.Unlock()
a.serverError(w, pe)
return
}
if !policy.AnonymousUpload {
a.mu.Unlock()
fail(w, 403, "上传已关闭")
return
}
tx, e := a.db.Begin()
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
if attempts > 0 {
var charged int64
e = tx.QueryRow("SELECT bytes FROM usage WHERE subject=? AND day=?", "ip:"+a.keyed(sessionIP), day).Scan(&charged)
if e == nil && charged > policy.IPDailyBytes-expected {
tx.Rollback()
a.mu.Unlock()
fail(w, 429, "重试上传超过日流量限制")
return
}
if e == nil {
for _, subject := range []string{"ip:" + a.keyed(sessionIP), "visitor:" + visitor} {
_, e = tx.Exec("UPDATE usage SET bytes=bytes+? WHERE subject=? AND day=?", expected, subject, day)
if e != nil {
break
}
}
}
if e == nil {
_, e = tx.Exec("UPDATE files SET charged=charged+? WHERE id=?", expected, fid)
}
}
if e == nil {
_, e = tx.Exec("UPDATE files SET attempts=attempts+1 WHERE id=?", fid)
}
if e == nil {
e = tx.Commit()
} else {
tx.Rollback()
}
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
a.activeFiles[fid] = true
a.mu.Unlock()
defer func() { a.mu.Lock(); delete(a.activeFiles, fid); a.mu.Unlock() }()
tmp := "tmp/" + fid
_ = a.root.Remove(tmp)
f, e := a.root.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
a.serverError(w, e)
return
}
defer a.root.Remove(tmp)
var dst io.Writer = f
var enc io.WriteCloser
if a.cfg.Encryption {
enc, e = age.Encrypt(f, a.identity.Recipient())
if e != nil {
f.Close()
a.serverError(w, e)
return
}
dst = enc
}
h := sha256.New()
r.Body = http.MaxBytesReader(w, r.Body, expected)
totalDeadline := time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second)
if sessionEnd := time.Unix(expiry, 0); sessionEnd.Before(totalDeadline) {
totalDeadline = sessionEnd
}
src := &idleReader{reader: r.Body, controller: http.NewResponseController(w), total: totalDeadline, idle: time.Duration(a.cfg.TransferIdleSeconds) * time.Second}
received, e := io.CopyBuffer(io.MultiWriter(dst, h), src, make([]byte, 128<<10))
if e == nil && received != expected {
e = errors.New("实际大小与声明不一致")
}
if e == nil && enc != nil {
e = enc.Close()
}
if e == nil {
e = f.Sync()
}
st, se := f.Stat()
ce := f.Close()
if e == nil {
e = ce
}
if e == nil {
e = se
}
a.mu.Lock()
defer a.mu.Unlock()
if _, ue := a.db.Exec("UPDATE files SET received=received+? WHERE id=?", received, fid); ue != nil {
a.serverError(w, ue)
return
}
if e != nil {
fail(w, 400, "文件接收失败或大小与声明不一致,请重试")
return
}
stored := allocated(st)
if stored > reserved {
fail(w, 507, "文件实际落盘开销超出预留")
return
}
if e = a.db.QueryRow("SELECT state FROM parcels WHERE id=?", id).Scan(&state); e != nil || state != "uploading" || expiry <= a.now().Unix() {
fail(w, 409, "上传已取消")
return
}
keyID := "plain"
if a.cfg.Encryption {
keyID = tokenHash(a.identity.Recipient().String())
}
digest := a.keyed(hex.EncodeToString(h.Sum(nil)) + "/" + keyID)
if !a.cfg.Deduplication {
digest += "/" + randomID()
}
var oid string
e = a.db.QueryRow("SELECT id FROM objects WHERE digest=? AND size=?", digest, expected).Scan(&oid)
if e != nil && !errors.Is(e, sql.ErrNoRows) {
a.serverError(w, e)
return
}
newObject := oid == ""
committed := false
defer func() {
if newObject && oid != "" && !committed {
if de := a.root.Remove(objectPath(oid)); de != nil && !errors.Is(de, os.ErrNotExist) {
a.extraDisk += stored
a.log.Error("orphan removal failed", "error", de)
}
}
}()
if newObject {
oid = randomID()
path := objectPath(oid)
if e = a.root.MkdirAll(filepath.Dir(path), 0700); e != nil {
a.serverError(w, e)
return
}
if e = a.root.Rename(tmp, path); e != nil {
a.serverError(w, e)
return
}
if e = syncDir(a.root, filepath.Dir(path)); e != nil {
a.serverError(w, e)
return
}
if e = syncDir(a.root, "tmp"); e != nil {
a.serverError(w, e)
return
}
} else {
if _, e = a.root.Stat(objectPath(oid)); e != nil {
a.serverError(w, e)
return
}
if e = a.root.Remove(tmp); e != nil {
a.serverError(w, e)
return
}
}
tx, e = a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
if newObject {
_, e = tx.Exec("INSERT INTO objects VALUES(?,?,?,?,?,?,?)", oid, digest, expected, stored, a.cfg.Encryption, keyID, a.now().Unix())
if e != nil {
_ = a.root.Remove(objectPath(oid))
a.serverError(w, e)
return
}
}
var attached sql.Result
attached, e = tx.Exec("UPDATE files SET object_id=?,status='ready',reserved=0 WHERE id=? AND status='pending'", oid, fid)
if e == nil {
n, _ := attached.RowsAffected()
if n != 1 {
e = errors.New("文件已被管理员删除")
}
}
if e == nil {
e = tx.Commit()
}
if e != nil {
if newObject {
_ = a.root.Remove(objectPath(oid))
}
a.serverError(w, e)
return
}
committed = true
respond(w, 200, map[string]bool{"ready": true})
}
func (a *App) publish(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var state string
var days, lifetime int
var uploadExpiry int64
e := a.db.QueryRow("SELECT state,days,lifetime_days,upload_expires FROM parcels WHERE id=?", id).Scan(&state, &days, &lifetime, &uploadExpiry)
if e != nil {
a.serverError(w, e)
return
}
if state == "ready" {
respond(w, 200, map[string]any{"published": true, "message": "已发布;如丢失取件码,请重新生成"})
return
}
policy, pe := a.policy()
if pe != nil {
a.serverError(w, pe)
return
}
if !policy.AnonymousUpload {
fail(w, 403, "上传已关闭")
return
}
if state != "uploading" || uploadExpiry <= a.now().Unix() {
fail(w, 409, "上传会话已失效")
return
}
var pending int
e = a.db.QueryRow("SELECT count(*) FROM files WHERE parcel=? AND status!='ready'", id).Scan(&pending)
if e != nil {
a.serverError(w, e)
return
}
if pending > 0 {
fail(w, 409, "尚有文件未上传完成")
return
}
now := a.now().Unix()
expires := now + int64(days)*86400
hard := now + int64(lifetime)*86400
code := pickupCode()
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
_, e = tx.Exec("UPDATE parcels SET state='ready',created=?,expires=?,hard_expires=?,code_hash=? WHERE id=?", now, expires, hard, a.keyed(code), id)
if e == nil {
_, e = tx.Exec("UPDATE files SET expires=?,hard_expires=? WHERE parcel=?", expires, hard, id)
}
if e == nil {
e = tx.Commit()
}
if e != nil {
a.serverError(w, e)
return
}
if e = a.settleUsage(id); e != nil {
a.serverError(w, e)
return
}
a.audit("parcel.publish", id, a.remoteIP(r))
respond(w, 200, a.shareResult(r, code, expires))
}
func (a *App) shareResult(r *http.Request, code string, expires int64) map[string]any {
links := []map[string]string{}
for _, base := range a.cfg.accessOrigins() {
label := "公网HTTP"
if strings.HasPrefix(base, "https://") {
label = "公网HTTPS"
}
u, _ := url.Parse(base)
ip := net.ParseIP(u.Hostname())
if ip != nil && (ip.IsPrivate() || ip.IsLoopback()) {
label = "内网入口"
}
links = append(links, map[string]string{"label": label + " · " + base, "url": base + "/#/pickup/" + code})
}
return map[string]any{"code": code, "url": a.accessFor(r).base + "/#/pickup/" + code, "urls": links, "expires": expires}
}
func (a *App) regenerate(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var expiry int64
if e := a.db.QueryRow("SELECT expires FROM parcels WHERE id=? AND state='ready' AND EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0 AND expires>?)", id, a.now().Unix()).Scan(&expiry); e != nil || expiry <= a.now().Unix() {
fail(w, 409, "包裹不可取件")
return
}
code := pickupCode()
if _, e := a.db.Exec("UPDATE parcels SET code_hash=?,version=version+1 WHERE id=?", a.keyed(code), id); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, a.shareResult(r, code, expiry))
}
func (a *App) revoke(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
if _, e := a.db.Exec("UPDATE parcels SET state='revoked',version=version+1 WHERE id=?", id); e != nil {
a.serverError(w, e)
return
}
a.audit("parcel.revoke", id, a.remoteIP(r))
respond(w, 200, map[string]bool{"revoked": true})
}
func (a *App) uploadStatus(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var state string
var created, expires int64
if e := a.db.QueryRow("SELECT state,created,expires FROM parcels WHERE id=?", id).Scan(&state, &created, &expires); e != nil {
a.serverError(w, e)
return
}
files, e := a.fileList(id)
if e != nil {
a.serverError(w, e)
return
}
respond(w, 200, map[string]any{"id": id, "state": state, "created": created, "expires": expires, "files": files})
}
func (a *App) mine(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
v := a.currentVisitor(r)
if v == "" {
respond(w, 200, []any{})
return
}
rows, e := a.db.Query("SELECT p.id,p.state,p.created,p.expires,(SELECT count(*) FROM files WHERE parcel=p.id),(SELECT COALESCE(sum(size),0) FROM files WHERE parcel=p.id) FROM parcels p WHERE visitor=? ORDER BY created DESC LIMIT 100", v)
if e != nil {
a.serverError(w, e)
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, state string
var created, expires, size int64
var n int
if e = rows.Scan(&id, &state, &created, &expires, &n, &size); e != nil {
a.serverError(w, e)
return
}
out = append(out, map[string]any{"id": id, "state": state, "created": created, "expires": expires, "count": n, "size": size})
}
if e = rows.Err(); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, out)
}
View File
Whitespace-only changes.
+12
View File
@@ -0,0 +1,12 @@
package web
import (
"embed"
"io/fs"
"net/http"
)
//go:embed dist
var assets embed.FS
func Handler() http.Handler { sub, _ := fs.Sub(assets, "dist"); return http.FileServer(http.FS(sub)) }