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