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

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

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
/build/
/var/
/config.toml
web/node_modules/
web/test-results/
web/playwright-report/
internal/web/dist/*
!internal/web/dist/.gitkeep
.bluebird-smoke-*/
/demo/
# Local credentials, backups and interpreter/editor caches
.env
.env.*
!.env.example
*.key
*.pem
*.db
*.db-wal
*.db-shm
*.log
__pycache__/
*.py[cod]
.DS_Store
*.swp
*.swo
+17
View File
@@ -0,0 +1,17 @@
.PHONY: build web test check dev release release-amd64
web:
./scripts/build.sh web
build:
./scripts/build.sh build
test:
go test -race ./...
check:
go vet ./...
npm --prefix web run build
dev:
go run ./cmd/bluebird serve --config config.toml
release:
./scripts/build.sh release
release-amd64:
./scripts/build.sh release amd64
+112
View File
@@ -0,0 +1,112 @@
# 蓝鸟文件快递柜
匿名寄件、凭码取件,管理员独立登录。Go + Vue 3 + SQLite,本机文件存储。相同内容完整上传校验后共用一个对象,分享权限、次数和自动续期按每个文件独立计算。支持取件密码和可选 age 落盘加密。
## 从源码构建并运行
需要 Go(见 go.mod)和 Node.js 仅用于构建,构建脚本无需 make。运行发布包不需要它们,也不需要外部数据库。
```sh
./scripts/build.sh build
cp deploy/config.example.toml config.toml
./build/bluebird init --config config.toml
./build/bluebird serve --config config.toml
```
打开 `http://localhost:8080`。首次初始化在终端设置管理员密码,无默认密码。自动化可通过 `BLUEBIRD_ADMIN_PASSWORD` 环境变量传入,避免命令行参数泄露密码。默认只监听回环地址;可通过 `access_urls` 增加公网HTTP、HTTPS和独立内网IP入口。浏览器cookie按入口协议自动处理,主入口的 `secure_cookies` 与其协议一致。内网HTTP需显式开启 `allow_lan_http`,并绑定可访问的内网地址。
## 直接运行发布包
解压对应架构的发布包,进入解压目录后运行,无需安装Go、Node.js或数据库服务:
```sh
./bluebird init --config config.toml
./bluebird serve --config config.toml
```
默认地址为 `http://localhost:8080`。其他访问入口按部署指南配置。
## Linux 服务器安装
Linux amd64发布包包含首次安装脚本,默认将二进制、配置及数据安装到 `/opt/bluebird`,使用独立bluebird账号和systemd运行,不覆盖已有安装。运行时无需Go、Node.js或外部数据库。
```sh
mkdir bluebird-release
tar -xzf bluebird-linux-amd64.tar.gz -C bluebird-release
cd bluebird-release
sudo ./install.sh --url https://files.example.com \
--access-url http://files.example.com \
--lan-url http://192.168.1.10:8080 --encrypt --no-start
```
将域名替换成自己的,按提示设置管理员密码。配置好已有HTTPS代理后启动:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now bluebird.service
```
完整步骤、内网HTTP选项、目录权限、备份及升级见 [部署指南](docs/deployment.md)。脚本不修改代理、DNS或防火墙。管理员通过 `https://你的域名/#/admin` 登录。
匿名会话7天、默认每天每 IP 最多20批及1GiB、默认单文件100MiB/每批10个/总500MiB。cookie用于寄件历史和所有权,不能替代 IP 限额;NAT 多人会共享 IP 限额,IPv6 按 /64 归并。管理策略可调整这些值。系统不试图证明一个匿名会话对应一个自然人。可在“我的寄件”导出管理凭据,换浏览器后通过同页导入管理。
`renewal_mode="add"` 默认成功下载延长3天,总保留期最多30天,剩余次数不恢复。初始保留最多7天与总保留期上限是两个控制项。下载后仅该文件续期;最后一次下载不再续期,因为此时已无下载权限。
## 存储、加密和配额
对象 ID 随机,原文件名不作为服务器路径;上传内容不执行、不解压、不预览。下载统一附件类型。设置 `encryption=true` 后,新对象直接流式加密落盘;文件/密钥同时泄露时不能提供额外保密,也不是端到端加密。备份 `var/secrets`,不要删掉既有密钥;缺失加密私钥时服务拒绝启动。
默认跨包裹去重,相同内容、相同存储加密格式/密钥只存一份。服务端完整接收并计算哈希,不接受客户端“已有哈希免上传”。这能节省长期实体占用,不能省网络流量,也不能省上传期间的临时预留。剩余空间不足时,即使文件可能重复,也可能先被拒绝;不要依赖去重保证磁盘装得下。
文件预算原子预留、临时文件计入预算,独立管理元数据和隔离对象。管理员可在“容量与保留”调整文件预算、数据总预算、卷最少保留空间及审计的天数、条数、内容预算;保存后立即生效并持久化,重启不会被TOML初始值覆盖。应用预算是保守接收控制,严格整站磁盘硬上限请设置项目配额/定容卷,见 [运维说明](docs/operations.md)。运行日志默认进入 journald,由主机单独限制其容量。
## 备份、恢复和升级
先停止 systemd 服务;维护 CLI 与服务使用同一个独占进程锁。
```sh
sudo systemctl stop bluebird
sudo -u bluebird /opt/bluebird/bluebird backup --config /opt/bluebird/config.toml --output /备份目标/bluebird.tar.gz
sudo systemctl start bluebird
```
备份输出须位于数据目录之外、目录需对运行账号可写。归档带数据库、对象、密钥和配置,所有内容均为敏感数据;备份可由外部备份系统进一步加密。恢复配置指向一个空数据目录:
```sh
./bluebird restore --config /新安装目录/config.toml --input /备份目标/bluebird.tar.gz
./bluebird check --config /新安装目录/config.toml
```
原配置副本存为数据目录中的 `restored-config.toml`,供管理员核对,不自动覆盖新主机配置。恢复后所有旧取件码、匿名管理凭据及会话失效,包裹显示“恢复待审核”。管理员可显式重新发布并设置新分享授权,避免回滚快照重开已撤销分享。需重置管理员密码时停止服务后执行 `reset-password`。
新版发布包包含 `upgrade.sh`,自动停服、一致性备份、保留配置与数据、替换程序并检查后启动。可用 `--access-url` 和 `--lan-url` 添加入口,具体命令见部署指南。当前数据库结构版本1;不支持结构降级,后续不兼容迁移需恢复配套备份。
## 开发与检查
以下命令在源码目录中运行。
```sh
go test -race ./... # Go race + 业务/故障集成测试
go vet ./...
npm --prefix web run build # Vue 类型检查及构建
./scripts/build.sh release amd64 # Linux amd64部署包、安装脚本、校验和
# ./scripts/build.sh release # 同时生成amd64/arm64包
```
前端开发:`npm --prefix web run dev`,代理本机8080后端。此时 Origin 仍需匹配配置的 `public_url` 或 `access_urls`,如把 public_url 设为 `http://localhost:5173`。
测试步骤见 [测试说明](docs/testing.md)。API 见 [接口文档](docs/api.md),备份、容量与清理规则见 [运维说明](docs/operations.md)。
首版不包含断点续传、服务端自动压缩、在线预览、端到端加密或病毒扫描。系统限制内容执行及资源使用,不能保证收件人下载的任意文件无病毒。
## 页面、身份与审计
产品显示名称为“蓝鸟”,主色为 #1677ff,使用蓝底白鸟SVG标志及多尺寸favicon。公共页面不展示管理入口;管理员通过 `/#/admin` 登录独立控制台,寄件/取件通过公共页面匿名使用。
“开放寄件服务”和“开放取件服务”是全站开关,关闭不会出现注册流程,管理员身份也不绕过匿名服务开关。
浏览器识别cookie从首次访问起固定有效7天,访问不会自动续期;“我的寄件”显示实际到期时间。原始管理凭据暂存在当前标签页的sessionStorage,关闭标签页会丢失,可事先导出JSON到其他设备导入。管理凭据只证明对应寄件所有权,不能延长文件保留期或恢复到期/删除文件;记录尚在时显示历史状态,记录已清理则提示凭据失效。
审计默认保留30天、最多100000条、32MiB估算内容预算,任一上限先达到时清理最旧记录。写入时执行条数和内容上限,后台每分钟清理到期记录。估算包括UTF-8字段与每条开销,不等于整个SQLite文件大小;删除后数据库复用空闲页,严格物理上限由文件系统配额约束。
标志源文件位于 `web/public/logo.svg`,网站图标位于同目录。修改SVG后可运行 `node scripts/icons.mjs` 生成ICO、PNG及手机图标,再构建前端。
File diff suppressed because it is too large. Load diff
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"bluebird/internal/app"
"bluebird/internal/web"
"golang.org/x/term"
)
type accessURLFlags []string
func (v *accessURLFlags) String() string { return strings.Join(*v, ",") }
func (v *accessURLFlags) Set(raw string) error { *v = append(*v, raw); return nil }
func main() {
if e := run(); e != nil {
log.Print(e)
os.Exit(1)
}
}
func run() error {
if len(os.Args) < 2 {
return errors.New("用法: bluebird init|serve|validate-config|configure-access|check|cleanup|backup|restore|reset-password --config config.toml")
}
command := os.Args[1]
flags := flag.NewFlagSet(command, flag.ContinueOnError)
config := flags.String("config", "config.toml", "配置文件")
username := flags.String("username", "admin", "管理员名称")
output := flags.String("output", "", "备份输出路径")
input := flags.String("input", "", "备份输入路径")
var accessURLs accessURLFlags
flags.Var(&accessURLs, "access-url", "增加访问入口,可重复")
listen := flags.String("listen", "", "访问监听地址")
lanURL := flags.String("lan-url", "", "内网直连入口")
allowLAN := flags.Bool("allow-lan-http", false, "允许明确配置的内网HTTP入口")
if e := flags.Parse(os.Args[2:]); e != nil {
return e
}
if command == "configure-access" {
if *lanURL != "" {
address, base, err := app.LANListen(*lanURL)
if err != nil {
return err
}
if *listen == "" {
*listen = address
}
accessURLs = append(accessURLs, base)
*allowLAN = true
}
if e := app.UpdateAccessConfig(*config, *listen, accessURLs, *allowLAN); e != nil {
return e
}
fmt.Println("访问入口配置已更新")
return nil
}
c, e := app.LoadConfig(*config)
if e != nil {
return e
}
if command == "validate-config" {
fmt.Println("配置检查通过")
return nil
}
if command == "restore" {
if *input == "" {
return errors.New("需指定 --input")
}
return app.Restore(c, *input)
}
a, e := app.Open(c)
if e != nil {
return e
}
defer a.Close()
switch command {
case "init", "reset-password":
if command == "init" && a.Initialized() {
return errors.New("已初始化;重置请使用 reset-password")
}
password := os.Getenv("BLUEBIRD_ADMIN_PASSWORD")
if password == "" {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return errors.New("需使用交互终端或 BLUEBIRD_ADMIN_PASSWORD 环境变量")
}
fmt.Print("管理员密码(至少8字节): ")
b, e := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if e != nil {
return e
}
password = string(b)
fmt.Print("再次输入: ")
again, e := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if e != nil {
return e
}
if password != string(again) {
return errors.New("两次密码不同")
}
}
if e = a.SetAdmin(*username, password); e != nil {
return e
}
fmt.Println("管理员配置完成")
return nil
case "check":
if e = a.Check(); e != nil {
return e
}
fmt.Println("数据库与实体内容完整性检查通过")
return nil
case "cleanup":
return a.Cleanup()
case "backup":
if *output == "" {
return errors.New("需指定 --output")
}
return a.Backup(*config, *output)
case "serve":
if !a.Initialized() {
return errors.New("请先执行 init")
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
maintenanceStop := make(chan struct{})
maintenanceDone := make(chan struct{})
go func() { defer close(maintenanceDone); a.RunMaintenance(maintenanceStop) }()
server := &http.Server{Addr: c.Listen, Handler: a.Handler(web.Handler()), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 32 << 10}
result := make(chan error, 1)
go func() {
if c.TLSCert != "" {
result <- server.ListenAndServeTLS(c.TLSCert, c.TLSKey)
} else {
result <- server.ListenAndServe()
}
}()
log.Printf("蓝鸟正在监听 %s (%s)", c.Listen, strings.TrimRight(c.PublicURL, "/"))
select {
case e = <-result:
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
e = server.Shutdown(shutdown)
if e != nil {
_ = server.Close()
}
}
close(maintenanceStop)
<-maintenanceDone
a.WaitRequests()
if errors.Is(e, http.ErrServerClosed) {
return nil
}
return e
default:
return errors.New("未知命令")
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"os"
"path/filepath"
"testing"
"bluebird/internal/app"
"github.com/pelletier/go-toml/v2"
)
func TestValidateConfigDoesNotCreateRuntimeData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
args := os.Args
t.Cleanup(func() { os.Args = args })
os.Args = []string{"bluebird", "validate-config", "--config", path}
cfg := app.DefaultConfig()
for _, invalid := range []bool{false, true} {
if invalid {
cfg.PublicURL = "https://files.example.com"
}
encoded, err := toml.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(path, encoded, 0600); err != nil {
t.Fatal(err)
}
err = run()
if (err != nil) != invalid {
t.Fatalf("validation error=%v invalid=%v", err, invalid)
}
if _, err = os.Stat(filepath.Join(dir, "var")); !os.IsNotExist(err) {
t.Fatal("configuration validation initialized runtime data")
}
}
}
+16
View File
@@ -0,0 +1,16 @@
# 两个明确入口:HTTP直接提供服务,HTTPS使用自动证书。
# 代理异机时改为蓝鸟实际内网IP,并将代理IP加入trusted_proxies。
{
auto_https disable_redirects
}
http://share.example.com {
reverse_proxy 192.168.1.10:8080 {
flush_interval -1
# 不配置request_buffers/response_buffers;流式传输,避免额外磁盘副本
}
}
https://share.example.com {
reverse_proxy 192.168.1.10:8080 {
flush_interval -1
}
}
+35
View File
@@ -0,0 +1,35 @@
[Unit]
Description=Bluebird anonymous file cabinet
After=network.target
[Service]
Type=simple
User=bluebird
Group=bluebird
WorkingDirectory=/opt/bluebird
ExecStart=/opt/bluebird/bluebird serve --config /opt/bluebird/config.toml
Restart=on-failure
RestartSec=5
TimeoutStopSec=90
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bluebird/var
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
LockPersonality=true
MemoryMax=768M
TasksMax=256
LimitNOFILE=4096
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+46
View File
@@ -0,0 +1,46 @@
listen = "127.0.0.1:8080"
public_url = "http://localhost:8080" # 主入口,支持HTTP或HTTPS
access_urls = [] # 额外入口,如 ["http://files.example.com", "http://192.168.1.10:8080"]
secure_cookies = false # 主入口HTTPS时为true;各入口按自身协议设置cookie
allow_lan_http = false # 私有IP HTTP入口需显式开启;纯内网模式需绑定具体私有IP
# 相对路径相对于此配置文件,而非进程当前目录
data_dir = "var"
encryption = false
deduplication = true
trusted_proxies = [] # 仅填实际反向代理 CIDR,如 127.0.0.1/32
# 二进制字节单位,文件预算须小于数据预算至少64MiB
data_budget = 10737418240
file_budget = 9663676416
min_free_bytes = 1073741824
# 以下容量和审计值仅用于首次初始化;之后在管理员后台管理
audit_retention_days = 30
audit_max_records = 100000
audit_max_bytes = 33554432
max_uploads = 8
max_downloads = 16
max_ip_uploads = 2
max_ip_downloads = 4
max_records = 200000
max_transfer_seconds = 3600
transfer_idle_seconds = 30
# 内置 HTTPS 可选,否则由已有代理终止 TLS
# tls_cert = "cert.pem"
# tls_key = "key.pem"
[policy]
anonymous_upload = true
anonymous_download = true
max_file_bytes = 104857600
max_batch_bytes = 524288000
max_files = 10
default_days = 1
max_initial_days = 7
max_lifetime_days = 30
default_downloads = 5
max_downloads = 20
renewal_days = 3
renewal_mode = "add" # add:现到期+3天;sliding:至少从下载完成保留3天
ip_daily_bytes = 1073741824
ip_daily_uploads = 20
session_daily_uploads = 20
require_password = false
+174
View File
@@ -0,0 +1,174 @@
#!/bin/sh
# Fresh installation; existing services, configuration and data are never replaced.
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
umask 077
usage() {
cat <<'HELP'
用法:sudo ./install.sh --url 站点地址 [选项]
--url URL 完整站点地址,如 https://files.example.com
--prefix DIR 安装目录,默认 /opt/bluebird
--listen ADDR 后端监听地址,HTTPS代理默认 127.0.0.1:8080
--lan-http 允许绑定具体私有IP提供内网HTTP服务
--access-url URL 增加其他HTTP/HTTPS入口,可重复
--lan-url URL 增加内网IP入口,并默认监听该IP和端口
--encrypt 启用新文件的服务端落盘加密
--no-start 安装并初始化,但暂不启用/启动systemd服务
-h, --help 显示帮助
首次安装会在终端询问管理员密码,账号为 admin。
自动化可通过 BLUEBIRD_ADMIN_PASSWORD 环境变量传入;密码不写入配置文件。
系统需使用systemd,并提供getent、useradd、groupadd、runuser和coreutils。
HTTPS证书由你已有的反向代理管理;脚本不安装代理或修改防火墙。
已有安装请按 docs/operations.md 升级,脚本不会覆盖它。
HELP
}
die() { printf '%s\n' "安装失败:$*" >&2; exit 1; }
require_value() { [ "$#" -ge 2 ] && [ -n "$2" ] || die "$1 缺少参数"; }
prefix=/opt/bluebird
public_url=
listen=
lan_http=false
extra_urls=
lan_url=
encryption=false
start_service=true
while [ "$#" -gt 0 ]; do
case "$1" in
--url) require_value "$@"; public_url=$2; shift 2 ;;
--prefix) require_value "$@"; prefix=$2; shift 2 ;;
--listen) require_value "$@"; listen=$2; shift 2 ;;
--lan-http) lan_http=true; shift ;;
--access-url|--lan-url)
require_value "$@"
case "$2" in *[[:space:]]*|*'"'*|*'\'*) die '额外入口不能包含空白、引号或反斜杠' ;; esac
extra_urls="$extra_urls\"$2\","
if [ "$1" = --lan-url ]; then lan_url=$2; lan_http=true; fi
shift 2 ;;
--encrypt) encryption=true; shift ;;
--no-start) start_service=false; shift ;;
-h|--help) usage; exit 0 ;;
*) die "未知选项:$1" ;;
esac
done
[ -n "$public_url" ] || { usage; exit 1; }
[ "$(id -u)" = 0 ] || die '请使用sudo或root运行'
[ "$(uname -s)" = Linux ] || die '仅支持Linux'
# Keep TOML and systemd paths literal; reject newline, quote and expansion syntax.
case "$public_url" in *[[:space:]]*|*'"'*|*'\'*) die '站点地址不能包含空白、引号或反斜杠' ;; esac
case "$listen" in *[[:space:]]*|*'"'*|*'\'*) die '监听地址格式无效' ;; esac
printf '%s\n' "$prefix" | grep -Eq '^/[A-Za-z0-9._/-]+$' || die '安装目录须为不含空格的绝对路径'
prefix=$(realpath -m -- "$prefix")
[ "$prefix" != / ] || die '不能安装到根目录'
case "$prefix" in /home/*|/root/*) die 'systemd保护home目录,请安装到/opt或/srv等目录' ;; esac
[ ! -e "$prefix" ] && [ ! -L "$prefix" ] || die "$prefix 已存在,未修改已有安装"
unit=/etc/systemd/system/bluebird.service
[ ! -e "$unit" ] && [ ! -L "$unit" ] || die "$unit 已存在,未修改已有服务"
package_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
[ -x "$package_dir/bluebird" ] && [ -r "$package_dir/config.toml" ] && [ -r "$package_dir/ARCH" ] || die '请从解压后的发布包运行install.sh'
case "$(cat "$package_dir/ARCH")" in
amd64) [ "$(uname -m)" = x86_64 ] || die '需要Linux amd64/x86_64主机' ;;
arm64) [ "$(uname -m)" = aarch64 ] || die '需要Linux arm64/aarch64主机' ;;
*) die '发布包架构信息无效' ;;
esac
for command in getent useradd groupadd runuser install mktemp realpath awk sed; do
command -v "$command" >/dev/null 2>&1 || die "缺少系统命令:$command"
done
if [ "$start_service" = true ]; then
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] || die 'systemd未运行;离线安装请使用--no-start'
fi
explicit_listen=$listen
public_url=${public_url%/}
case "$public_url" in
https://*) secure_cookies=true; trusted_proxies='["127.0.0.1/32", "::1/128"]'; [ -n "$listen" ] || listen=127.0.0.1:8080 ;;
http://*) secure_cookies=false; trusted_proxies='[]'
authority=${public_url#http://}
if [ -z "$listen" ]; then
case "$authority" in
localhost:*|127.*:*|10.*:*|192.168.*:*|172.*:*|\[*\]:*) listen=$authority ;;
localhost|127.*|10.*|192.168.*|172.*|\[*\]) die '内网HTTP地址须包含端口,如http://192.168.1.10:8080' ;;
*) listen=127.0.0.1:8080 ;;
esac
case "$listen" in localhost:*) listen="127.0.0.1:${listen#localhost:}" ;; esac
fi ;;
*) die '站点地址须以http://或https://开头' ;;
esac
if [ -n "$lan_url" ] && [ -z "$explicit_listen" ]; then
case "$lan_url" in http://*) listen=${lan_url#http://}; listen=${listen%/} ;; *) die '--lan-url须使用http://内网IP:端口' ;; esac
fi
port=${listen##*:}
case "$port" in ''|*[!0-9]*) die '监听地址须包含数字端口,如127.0.0.1:8080' ;; esac
[ "$port" -ge 1024 ] && [ "$port" -le 65535 ] || die '独立服务账号使用1024至65535端口;80/443交给反向代理'
parent=$(dirname -- "$prefix")
[ -d "$parent" ] || install -d -m 0755 -- "$parent"
stage=$(mktemp -d "$parent/.bluebird-install-XXXXXX")
cleanup() { [ -z "$stage" ] || rm -rf -- "$stage"; }
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
awk -v address="$listen" -v url="$public_url" -v secure="$secure_cookies" -v lan="$lan_http" -v encryption="$encryption" -v proxies="$trusted_proxies" -v extras="[$extra_urls]" '
/^listen = / {print "listen = \"" address "\"";next}
/^public_url = / {print "public_url = \"" url "\"";next}
/^access_urls = / {print "access_urls = " extras;next}
/^secure_cookies = / {print "secure_cookies = " secure;next}
/^allow_lan_http = / {print "allow_lan_http = " lan;next}
/^encryption = / {print "encryption = " encryption;next}
/^trusted_proxies = / {print "trusted_proxies = " proxies;next}
{print}
' "$package_dir/config.toml" > "$stage/config.toml"
# Validate before creating an account, database or final installation directory.
if [ -n "$lan_url" ]; then
"$package_dir/bluebird" configure-access --config "$stage/config.toml" --listen "$listen" --lan-url "$lan_url"
fi
"$package_dir/bluebird" validate-config --config "$stage/config.toml"
if ! getent group bluebird >/dev/null; then groupadd --system bluebird; fi
[ "$(getent group bluebird | cut -d: -f3)" != 0 ] || die 'bluebird组不能使用GID 0'
if ! getent passwd bluebird >/dev/null; then
nologin=/usr/sbin/nologin
[ -x "$nologin" ] || nologin=/sbin/nologin
[ -x "$nologin" ] || nologin=/bin/false
useradd --system --no-create-home --gid bluebird --home-dir "$prefix" --shell "$nologin" bluebird
fi
[ "$(id -u bluebird)" != 0 ] || die 'bluebird账号不能使用UID 0'
chown root:bluebird "$stage"
chmod 0750 "$stage"
install -o root -g bluebird -m 0750 "$package_dir/bluebird" "$stage/bluebird"
chown root:bluebird "$stage/config.toml"
chmod 0640 "$stage/config.toml"
install -d -o bluebird -g bluebird -m 0700 "$stage/var"
if [ -f "$package_dir/THIRD_PARTY_NOTICES.txt" ]; then
install -o root -g bluebird -m 0644 "$package_dir/THIRD_PARTY_NOTICES.txt" "$stage/THIRD_PARTY_NOTICES.txt"
fi
# runuser preserves the terminal and optional bootstrap environment variable.
runuser --preserve-environment -u bluebird -- "$stage/bluebird" init --config "$stage/config.toml"
unset BLUEBIRD_ADMIN_PASSWORD
sed "s|/opt/bluebird|$prefix|g" "$package_dir/deploy/bluebird.service" > "$stage/bluebird.service"
chown root:bluebird "$stage/bluebird.service"
chmod 0640 "$stage/bluebird.service"
# Stage and target share a filesystem; publish the initialized installation once.
[ ! -e "$prefix" ] && [ ! -L "$prefix" ] || die "$prefix 已出现,未覆盖它"
[ ! -e "$unit" ] && [ ! -L "$unit" ] || die "$unit 已出现,未覆盖它"
mv -T -- "$stage" "$prefix"
stage=
[ -d /etc/systemd/system ] || install -d -m 0755 /etc/systemd/system
install -m 0644 "$prefix/bluebird.service" "$unit"
if [ "$start_service" = true ]; then
systemctl daemon-reload
if ! systemctl enable --now bluebird.service; then
printf '%s\n' '文件和数据已保留;请执行 journalctl -u bluebird -n 50 查看启动错误。' >&2
exit 1
fi
fi
printf '%s\n' '' "蓝鸟已安装:$prefix" "配置:$prefix/config.toml" "数据、数据库和密钥:$prefix/var" "后端监听:$listen" "站点:$public_url" "管理地址:$public_url/#/admin(账号admin)"
if [ "$secure_cookies" = true ]; then
printf '%s\n' 'HTTPS入口通过反向代理访问;已配置的HTTP或内网入口也可使用,详见docs/deployment.md。'
fi
if [ -n "$extra_urls" ]; then printf '%s\n' "额外入口:[$extra_urls]"; fi
if [ "$start_service" = false ]; then
printf '%s\n' '服务尚未启动。配置好代理后执行:' ' systemctl daemon-reload' ' systemctl enable --now bluebird.service'
else
printf '%s\n' '已启用开机启动;查看状态:systemctl status bluebird'
fi
+92
View File
@@ -0,0 +1,92 @@
#!/bin/sh
# Upgrade the existing installation, retaining its data and administrator account.
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
umask 077
usage() {
cat <<'HELP'
用法:sudo ./upgrade.sh [--prefix /opt/bluebird] [--access-url URL ...] [--lan-url URL] [--listen IP:端口] [--no-start]
保留当前配置参数、文件、密钥和管理员密码;新增入口与指定监听地址会合并到配置中。
默认停止服务、一致性备份、更新程序及配置、检查后启动。失败时恢复旧程序和配置。
--no-start用于已手动停服的离线升级,升级后暂不启动服务。
HELP
}
die(){ printf '%s\n' "升级失败:$*" >&2;exit 1; }
value(){ [ "$#" -ge 2 ] && [ -n "$2" ] || die "$1缺少参数"; }
prefix=/opt/bluebird
additions=
lan_url=
listen=
no_start=false
while [ "$#" -gt 0 ];do
case "$1" in
--prefix) value "$@";prefix=$2;shift 2 ;;
--access-url) value "$@";case "$2" in *[[:space:]]*) die '访问地址不能含空白' ;;esac;additions="$additions $2";shift 2 ;;
--lan-url) value "$@";lan_url=$2;shift 2 ;;
--listen) value "$@";listen=$2;shift 2 ;;
--no-start) no_start=true;shift ;;
-h|--help) usage;exit 0 ;;
*) die "未知选项:$1" ;;
esac
done
[ "$(id -u)" = 0 ] || die '请使用sudo或root运行'
[ "$(uname -s)" = Linux ] || die '仅支持Linux'
prefix=$(realpath -m -- "$prefix")
[ -x "$prefix/bluebird" ] && [ -f "$prefix/config.toml" ] || die '未找到已有安装,请使用install.sh'
exec 9>"$prefix/.upgrade.lock"
flock -n 9 || die '另一升级进程正在运行'
package_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
[ -x "$package_dir/bluebird" ] && [ -r "$package_dir/ARCH" ] || die '请在新版发布包目录运行'
case "$(cat "$package_dir/ARCH"):$(uname -m)" in amd64:x86_64|arm64:aarch64) ;;*) die '发布包架构与主机不匹配' ;;esac
if [ "$no_start" = false ];then [ -d /run/systemd/system ] && command -v systemctl >/dev/null || die 'systemd未运行,先手动停服再使用--no-start';fi
candidate=$(mktemp "$prefix/.upgrade-config-XXXXXX.toml")
backup=
changed=false
was_active=false
success=false
cleanup(){
rm -f -- "$candidate" "$prefix/bluebird.new"
if [ "$success" = false ];then
if [ "$changed" = true ];then
if [ "$no_start" = false ];then systemctl stop bluebird.service || :;fi
install -o root -g bluebird -m 0750 "$backup/bluebird" "$prefix/bluebird.rollback"
mv -f -- "$prefix/bluebird.rollback" "$prefix/bluebird"
cp -p -- "$backup/config.toml" "$prefix/config.toml"
printf '%s\n' "已恢复旧程序和配置;备份保留在$backup" >&2
fi
if [ "$was_active" = true ] && [ "$no_start" = false ];then systemctl start bluebird.service || :;fi
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
cp -p -- "$prefix/config.toml" "$candidate"
set -- "$package_dir/bluebird" configure-access --config "$candidate"
[ -z "$listen" ] || set -- "$@" --listen "$listen"
[ -z "$lan_url" ] || set -- "$@" --lan-url "$lan_url"
# URLs are separate argv values, never shell code. Globbing is disabled.
set -f
for item in $additions;do set -- "$@" --access-url "$item";done
"$@"
"$package_dir/bluebird" validate-config --config "$candidate"
if [ "$no_start" = false ];then
if systemctl is-active --quiet bluebird.service;then was_active=true;fi
systemctl stop bluebird.service
fi
backup="$prefix/backups/upgrade-$(date -u +%Y%m%dT%H%M%SZ)-$$"
install -d -o bluebird -g bluebird -m 0700 "$backup"
cp -p -- "$prefix/config.toml" "$backup/config.toml"
install -o root -g bluebird -m 0750 "$prefix/bluebird" "$backup/bluebird"
runuser -u bluebird -- "$prefix/bluebird" backup --config "$prefix/config.toml" --output "$backup/data.tar.gz"
# No configuration/data reinitialization; keep all existing administrator hashes.
install -o root -g bluebird -m 0750 "$package_dir/bluebird" "$prefix/bluebird.new"
changed=true
mv -f -- "$prefix/bluebird.new" "$prefix/bluebird"
mv -f -- "$candidate" "$prefix/config.toml"
runuser -u bluebird -- "$prefix/bluebird" check --config "$prefix/config.toml"
if [ "$no_start" = false ];then systemctl start bluebird.service;systemctl is-active --quiet bluebird.service;fi
success=true
printf '%s\n' "升级完成:$prefix" "旧版本、配置及一致性数据备份:$backup"
if [ "$no_start" = true ];then printf '%s\n' '服务尚未启动,请执行:systemctl start bluebird.service';fi
+56
View File
@@ -0,0 +1,56 @@
# HTTP API(v0.1)
所有路径以 `/api/v1` 开始。JSON 错误为 `{"error":"说明"}`。修改请求带 `Origin`,应匹配 `public_url` 或 `access_urls` 中的完整站点地址。前端请求另携带 `X-Bluebird-Origin: location.origin`,该值也必须匹配入口且与Origin一致,便于反向代理转发后仍正确识别协议。浏览器使用同源 HttpOnly cookie,不开放 CORS。无 cookie 的脚本可使用匿名管理 Bearer 凭据;不要在 URL 中传凭据。
| 方法/路径 | 用途 |
| --- | --- |
| GET `/health` | 存活/初始化状态 |
| GET `/policy` | 公开有效策略、当前入口及可用入口 URL、初始化 7 天匿名会话 |
| POST `/uploads` | 清单声明、完整容量预留、生成上传会话与管理凭据 |
| PUT `/uploads/{id}/files/{file}` | 原始文件字节流,需同会话 cookie 或管理 Bearer |
| GET `/uploads/{id}` | 查询上传状态,需所有权凭据 |
| POST `/uploads/{id}/publish` | 所有文件就绪后发布,仅首次返回取件码 |
| GET `/mine` | 当前匿名会话最近100个包裹 |
| POST `/parcels/{id}/revoke` | 提前撤销 |
| POST `/parcels/{id}/code` | 重生成取件码,旧码/授权失效 |
| POST `/pickup/resolve` | `{code,password}`,校验后设置10分钟取件 cookie |
| GET `/pickup` | 当前授权包裹的文件清单 |
| GET `/pickup/files/{file}/download` | 附件下载;忽略 Range,HEAD 不扣次数 |
| POST `/admin/login` | `{username,password}`,设置8小时管理员 cookie |
| POST `/admin/logout` | 删除管理员会话 |
| GET `/admin/status` | 登录状态 |
| GET `/admin/stats` | 实体、逻辑、预留、预算及并发统计 |
| GET `/admin/parcels?page=1&page_size=10` | 分页包裹及文件;支持 `q`(文件名/包裹ID片段)、`state` |
| POST `/admin/parcels/{id}/delete` | 撤销包裹,后台回收 |
| POST `/admin/files/{id}/delete` | 删除单份文件引用,后台回收 |
| POST `/admin/parcels/{id}/reissue` | 显式重发恢复待审核包裹,使用当前默认期限/次数 |
| GET/PUT `/admin/policy` | 读取/修改完整业务策略 |
| GET/PUT `/admin/runtime` | 文件/数据预算、磁盘余量及审计天数/条数/内容预算 |
| GET `/admin/audit?page=1&page_size=20` | 分页审计;支持 `action`、`source`、`target`、`from`、`to` |
| POST `/admin/cleanup` | 触发清理 |
上传清单示例:
```json
{"files":[{"name":"example.zip","size":12345}],"days":7,"downloads":5,"password":"optional-password","renew":true}
```
响应包含 `id`、`manage_token` 和带服务生成文件 ID 的 `files`。凭据只保存到受控位置,不转发给收件人。实际文件长度必须与清单相符。会话最长1小时,进程重启会撤销尚未发布的会话。已就绪的文件重发 PUT 幂等返回成功;失败文件可重新完整上传。每次失败重试的传入内容计入日上传流量。
下载成功是服务器输出完整响应体,无法证明用户已保存。请求开始即扣次数,中断保守计一次;开始前确定没有发送内容则返还。仅成功传输且仍有剩余次数才续期;默认 `到期时间 = min(max(旧到期,完成时刻)+3天,发布时间+30天)`。另一模式为 `min(max(旧到期,完成时刻+3天),总期限上限)`。取件授权到期需重新输入取件码。
`GET /policy` 另返回 `visitor_expires`(当前浏览器身份到期Unix时间)。管理凭据用于所有权,过期/删除记录不开放重新取件,已清理记录返回404。
## 管理列表分页与搜索
包裹和审计列表统一返回 `{items,total,page,page_size,pages}`,其中 `total` 是符合当前筛选条件的总条数,`pages` 是总页数。空结果仍返回 `items:[]`、`page:1`、`pages:1`。页码从1开始;默认包裹每页10个、审计每页20条,`page_size` 支持1至100。超出最后一页时自动回到最后一页;非法页码、条数或筛选值返回400。分页仅加载当前页,数据库保留条数与页面每页条数相互独立。
包裹 `q` 匹配文件名或包裹ID片段,`state` 为精确状态。审计 `action` 匹配完整操作代码,`source` 和 `target` 匹配字面片段(百分号和下划线无通配作用);`from`、`to` 为Unix秒数,两端均包含,可以仅设置一端,开始时间不能晚于结束时间。多个条件同时满足才匹配。审计按事件ID倒序,包裹按寄出时间、ID倒序,保证同一秒内多条记录的排序明确。
示例:`/admin/audit?action=admin.delete&source=192.168.1.&from=1791158400&to=1791244800&page=1&page_size=20`。界面时间输入按管理员设备本地时区转换成Unix秒数;搜索和每页条数改变后回到第一页。
## 多入口与分享链接
`GET /policy` 的 `public_url` 返回当前访问入口,`access_urls` 返回所有配置入口。发布、重新生成取件码、管理员重新发布返回的 `url` 使用当前入口,`urls` 为可选分享入口列表 `[{label,url}]`;同一取件码在各入口下均有效,下载次数和期限共享。二维码随分享入口切换。
HTTP与HTTPS并存时,HTTP cookie使用 `_http` 后缀,HTTPS沿用原名称并设置Secure,防止同一域名的两种协议互相覆盖。不同入口的浏览器身份、登录状态及管理凭据存储可能独立;这不会改变文件及取件码。未知Origin、相互不一致的来源头和跨站请求仍被拒绝,不开放任意CORS。
+140
View File
@@ -0,0 +1,140 @@
# Linux amd64 部署
前端与SQLite已包含在二进制中,运行无需Go、Node.js、PostgreSQL或Valkey。脚本用于有systemd的Linux amd64/x86_64主机,需root或sudo,以及shadow、util-linux和coreutils(useradd、groupadd、getent、runuser、flock、install等)。
## 已有安装:添加公网HTTP和独立内网入口
本节以蓝鸟服务器192.168.1.10、独立反向代理和主入口 `https://files.example.com` 为例。将新版发布包与SHA256SUMS上传至工作目录,解压到一个新的空目录:
```sh
sha256sum -c SHA256SUMS
mkdir bluebird-release-new
tar -xzf bluebird-linux-amd64.tar.gz -C bluebird-release-new
cd bluebird-release-new
sudo ./upgrade.sh --lan-url http://192.168.1.10:8080 \
--access-url http://files.example.com
```
脚本先校验候选配置,随后自动停服、备份、更新程序和配置、执行完整性检查、启动服务。保留原管理员密码、数据库、文件、密钥、取件码、后台策略和其他配置;不会重新初始化。旧程序、原配置和一致性数据归档保留在 `/opt/bluebird/backups/upgrade-时间-进程号/`。备份含敏感信息,应复制到独立备份位置并按自己的容量策略清理。更新失败会恢复旧程序及配置;当前数据库结构版本仍为1。
升级后以下入口同时可用:
- 内网:`http://192.168.1.10:8080`,无需解析公网域名。
- 公网HTTP:`http://files.example.com`,由反向代理提供80端口。
- 公网HTTPS:`https://files.example.com`,由反向代理提供443端口和有效证书。
- 各入口的管理后台:在对应地址后添加 `/#/admin`。
内网入口直接连接蓝鸟。公网入口仍需域名正确解析到代理、代理转发到 `192.168.1.10:8080`;程序不能绕过公网DNS故障。主机网络/防火墙需允许内网客户端和代理连接8080。HTTP传输不加密;它可以正常上传、取件和管理登录。
## 首次安装
已有安装使用上面的upgrade.sh;install.sh不会覆盖已有目录或同名服务。首次部署在发布包解压目录运行:
```sh
sudo ./install.sh --url https://files.example.com \
--access-url http://files.example.com \
--lan-url http://192.168.1.10:8080 --encrypt --no-start
sudo systemctl daemon-reload
sudo systemctl enable --now bluebird.service
```
将域名、IP换成实际值。脚本提示输入两次管理员密码,账号admin,没有默认密码。`--encrypt`启用新上传文件的服务端落盘加密;`--no-start`安装并初始化,但暂不启动。也支持单独的公网HTTP域名:`--url http://files.example.com`,无需在域名后写8080,后端默认监听127.0.0.1:8080。纯内网部署可用 `--url http://192.168.1.10:8080 --lan-http`。
默认目录:
```text
/opt/bluebird/
├── bluebird # 二进制,前端内嵌
├── config.toml # root:bluebird / 0640
├── bluebird.service # 服务定义副本
├── THIRD_PARTY_NOTICES.txt
├── backups/ # 升级时生成的备份
└── var/ # bluebird:bluebird / 0700,数据库、对象、密钥
```
程序与配置由root管理,服务以bluebird账号运行,仅写var;systemd定义安装在 `/etc/systemd/system/bluebird.service`。不修改代理、DNS、防火墙。自动化初始化可用 `BLUEBIRD_ADMIN_PASSWORD` 环境变量,密码不写入配置或服务定义。
## 多入口配置
上述升级命令得到的相关配置为:
```toml
listen = "192.168.1.10:8080"
public_url = "https://files.example.com"
access_urls = ["http://192.168.1.10:8080", "http://files.example.com"]
secure_cookies = true
allow_lan_http = true
# 将下例替换成反向代理机器的实际IP,保留原有需要信任的代理:
# trusted_proxies = ["192.168.1.20/32"]
```
`public_url`是默认入口,`access_urls`增加精确允许的入口,最多16个,地址只包含协议、域名/IP和可选端口,不带路径。`secure_cookies`与默认入口协议一致;其他入口按实际协议自动设置cookie。HTTP与HTTPS同时使用同一域名时,HTTP cookie采用独立名称,避免覆盖Secure cookie。不同入口的浏览器会话和登录可能独立,文件和取件码在所有入口共用,下载额度与期限不会翻倍。
生成分享时默认采用当前访问入口,可在弹窗中选择其他入口;链接及二维码一起更新,取件码保持相同。内网寄件无需访问公网域名,即使公网DNS不可用也可完成全过程。
异机代理的真实IP/CIDR需要加入 `trusted_proxies`,使公网访客IP限额与审计准确。脚本不会猜测代理IP,升级保留旧值;不要设置为整个互联网或任意来源。配置修改后重启服务。程序拒绝未知Origin及相互矛盾的来源头,不使用无限制CORS。
## 代理同时提供HTTP和HTTPS
在反向代理机器上使用下例(域名与蓝鸟配置一致):
```caddyfile
{
auto_https disable_redirects
}
http://files.example.com {
reverse_proxy 192.168.1.10:8080 {
flush_interval -1
}
}
https://files.example.com {
reverse_proxy 192.168.1.10:8080 {
flush_interval -1
}
}
```
两个明确的站点块分别提供HTTP与HTTPS;不要仅使用自动将HTTP跳转到HTTPS的配置。Nginx等代理同样需将80和443的请求转发至这个后端,并转发Host、X-Forwarded-Host、X-Forwarded-Proto、X-Forwarded-For及前端的X-Bluebird-Origin。应避免上传磁盘缓冲、下载内容缓存,并配置匹配文件限制的请求大小及超时。不要将var配置成代理静态目录。
证书与公网DNS在代理侧管理;代理必须能连接192.168.1.10的8080端口。若使用其他软件管理代理,在该软件中为HTTP和HTTPS各开启对应入口。
## 检查与日常操作
```sh
sudo systemctl status bluebird --no-pager
sudo ss -lntp | grep ':8080'
sudo journalctl -u bluebird -n 50 --no-pager
```
应看到监听192.168.1.10:8080。没有curl也可直接在浏览器打开 `http://192.168.1.10:8080/api/v1/health`,预期 `{"ready":true}`,然后访问首页测试寄件和取件。检查公网HTTP和HTTPS时分别使用两个协议,不能依赖代理自动跳转;管理页均在 `/#/admin`。
默认文件预算9GiB、数据预算10GiB、卷最少保留1GiB;单文件100MiB、每批10个、总500MiB。管理员在“服务策略”“容量与保留”修改额度、期限、下载次数和审计规则。初始化后的后台设置持久化于SQLite,编辑TOML的初始值不会覆盖。
`--prefix`可指定其他已有安装目录;默认 `/opt/bluebird`。`--listen`覆盖监听地址,`--lan-url`未指定listen时自动绑定其IP和端口;独立账号监听1024至65535端口,公网80/443由代理负责。`upgrade.sh --no-start`用于手动停服后的离线升级,完成后暂不启动;必须先确认旧服务已停止,数据锁会拒绝并行维护。首次安装路径不能包含空格或位于/home、/root,systemd保护这些目录。
## 备份和密码重置
全部维护CLI需先停服,与服务共用独占数据锁:
```sh
sudo install -d -o bluebird -g bluebird -m 0700 /opt/bluebird/backups
sudo systemctl stop bluebird
sudo runuser -u bluebird -- /opt/bluebird/bluebird backup \
--config /opt/bluebird/config.toml \
--output /opt/bluebird/backups/bluebird-backup.tar.gz
sudo systemctl start bluebird
```
备份包含数据库、文件、配置和密钥,不能只复制运行中的SQLite主文件或只备份对象。升级保留旧取件码;执行restore恢复历史快照时旧分享及会话失效,管理员需逐项审核重新发布,规则见 [operations.md](operations.md)。
```sh
sudo systemctl stop bluebird
sudo runuser -u bluebird -- /opt/bluebird/bluebird reset-password --config /opt/bluebird/config.toml
sudo systemctl start bluebird
```
只校验配置、不打开数据库:
```sh
sudo /opt/bluebird/bluebird validate-config --config /opt/bluebird/config.toml
```
+35
View File
@@ -0,0 +1,35 @@
# 运维与容量
首次安装与systemd部署步骤见 [deployment.md](deployment.md)。
## 两层限制
`file_budget` 限制实体对象与尚未写入的预留之和,重复分享共用一个实体;声明清单仍按每个文件完整预留。`data_budget` 另考虑 SQLite/WAL、隔离对象及余量;`min_free_bytes` 防止侵占主机最后的可用空间。配额按文件系统分配块保守估计,不等同于原始文件大小。到期但尚未删除、删除失败或仍有下载引用的对象继续占用额度。
Btrfs等文件系统可能以inode总数0表示不提供统计,此时不根据free_inodes=0拒绝上传;记录上限及文件系统硬配额仍生效。
应用控制无法为 SQLite、文件系统目录元数据、系统日志及管理员外部写入提供字节级绝对上限。严格模式需为 `/opt/bluebird/var` 设置独立文件系统项目配额或定容卷;应用预算必须低于硬上限,建议另留至少1GiB,并限制 inode。先用主机实际文件系统支持的管理方式配置配额,再验证 `quota`/`xfs_quota` 等工具显示的限制;软件不自动格式化或修改磁盘。
如现有主机不支持项目配额,可将数据目录挂载到专用定容卷。数据库、objects和tmp放在同一可靠本机文件系统,tmp与objects必须支持原子rename。不要把SQLite WAL放到NFS/SMB。
journald 不在 `/opt/bluebird` 下,主机需设置 `SystemMaxUse` 等日志上限。外部反向代理、备份、TLS证书与发布包有各自容量;严格整机预算须一起计入。
## 清理与故障
文件传输默认最长1小时、读写空闲30秒,配置可调整。
默认每分钟回收失效引用,删除最后引用后才删除实体。正在传输的对象延后删除。进程重启撤销尚未发布的上传,避免部分文件可见;未能确认实际输入量的中断尝试按完整预留流量保守计费。日额度按UTC日期计算。孤儿实体移入不可访问的 quarantine,24小时后清理。文件或密钥异常时不通过生成替代对象恢复权限。
已失效包裹在其文件全部清理、且距记录寄出时间超过30天后删除元数据;审计默认30天/10万条/32MiB估算内容,可在后台“容量与保留”修改;最大文件/访客/授权数受 `max_records` 限制。达到容量或记录上限后拒绝新增,不偷偷删除尚有效的分享。
停止服务后可执行 `check`(SQLite完整性、全部对象解密/哈希及启动对账)和 `cleanup`。启动恢复会取消未发布会话,故维护命令需安排窗口;不是无副作用的在线巡检。
## 安全部署
二进制及配置归 root,服务以专用账号仅写 var。按示例 systemd 收紧权限。支持配置中的HTTP与HTTPS入口;HTTP传输不加密,HTTPS入口使用Secure cookie。只信任实际代理IP/CIDR。上传对象不可通过代理 alias/root 直接获取。
加密 private identity 和 HMAC 密钥都有恢复意义。密钥轮换目前仅支持开关混存和保留同一既有 identity,不提供后台轮换旧私钥;不要手工覆盖 age.key,否则既有文件无法读取。
静止备份会短暂中断服务。不要在运行中只复制主数据库文件,不要把不同时间的数据库和对象目录拼接。升级或恢复前保存配置及密钥副本。恢复得到的是历史快照,所以旧分享/会话全部失效,需要管理员逐项审核重新发布。
后台容量与审计设置保存在SQLite runtime_settings中,TOML的相应值仅首次初始化使用。降低文件预算会停止超出预算的新上传,不删除有效文件;降低审计预算会清理最旧事件。审计计数及内容量由SQLite触发器维护,避免每次日志写入扫描所有记录。
+75
View File
@@ -0,0 +1,75 @@
# 构建与测试
源码构建需要go.mod指定的Go版本、Node.js及npm。生产服务器只需要发布包,不需要这些构建工具。脚本先构建Vue前端,再将其嵌入Go二进制;internal/web/dist为生成目录,不提交到源码仓库。
## 构建
```sh
./scripts/build.sh build
# Linux amd64发布包及SHA256SUMS
./scripts/build.sh release amd64
# 同时生成Linux amd64和arm64发布包
./scripts/build.sh release
```
输出位于build,包含前端内嵌的程序、配置模板、安装/升级脚本、systemd单元、运行文档及第三方许可证。node_modules、build和测试结果不纳入Git。
## 后端检查
先构建前端,保证Go嵌入的静态资源已生成:
```sh
./scripts/build.sh web
go test -race ./...
go vet ./...
```
后端用例覆盖匿名所有权、密码取件、加密和去重、空间预留、下载计数与续期、并发传输、分页搜索、审计清理、来源校验、多入口cookie、备份恢复及故障回收。可单独运行较大数据量的并发测试:
```sh
BLUEBIRD_STRESS_MIB=64 go test ./internal/app -run TestConcurrentStreamingTransfers -v -count=1
```
## 浏览器测试
```sh
./scripts/build.sh build
cd web
npx playwright install chromium
npm run test:e2e
```
仅针对隔离测试实例运行,不能指向生产数据。默认实例监听127.0.0.1:18981;多入口用例另使用19081、19082,启动本机HTTPS代理和临时自签名证书,需要openssl。测试覆盖寄件、取件、管理凭据、剪贴板、手机布局、分页筛选,以及同一域名的HTTP/HTTPS和独立IP入口。
执行前确保这些端口未被占用。浏览器依赖、失败追踪保存在本机缓存和web/test-results,不能提交到仓库。Playwright主实例的临时数据目录由测试配置创建,可在测试结束后按实际路径清理;多入口用例自动回收其进程、证书及数据。手机布局采用Chromium模拟,不等同于Safari或手机实机验证。
## 原生发布包测试
Python脚本需要Python 3.12或更新版本:
```sh
./scripts/build.sh release amd64
python3 scripts/smoke.py
```
脚本在项目中的隔离临时目录验证发布包的初始化、内嵌前端、加密去重、下载、管理员接口、完整性检查及备份恢复,结束后自动回收进程和数据。
首次安装测试需要rootless Podman,每次使用一次性Debian容器:
```sh
podman run --rm --security-opt label=disable \
-e BLUEBIRD_INSTALL_SMOKE=1 \
-v "$PWD/build/linux-amd64:/pkg:ro" \
-v "$PWD/scripts/install-smoke.sh:/test.sh:ro" \
docker.io/library/debian:bookworm-slim sh /test.sh https
```
将末尾https换成http或lan可检查单独的公网HTTP与内网HTTP配置。label=disable仅用于此一次性容器读取本机测试文件,不修改主机SELinux配置。该脚本禁止直接在普通主机运行,不注册主机systemd服务。
已有安装升级测试需要保留一份旧版amd64部署包:
```sh
python3 scripts/upgrade-smoke.py /path/to/previous-bluebird-linux-amd64.tar.gz
```
此用例在一次性容器中验证旧密码、加密文件、密钥和取件码保留,新增入口下载、配置权限、一致性备份、非法配置保护和检查失败回滚。容器使用手动停服后的--no-start模式;目标服务器的systemd启停、DNS、代理、证书及防火墙需要在实际部署环境验收。
+25
View File
@@ -0,0 +1,25 @@
module bluebird
go 1.27.1
require (
filippo.io/age v1.3.2
github.com/pelletier/go-toml/v2 v2.4.3
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/time v0.16.0
modernc.org/sqlite v1.60.1
)
require (
filippo.io/hpke v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+64
View File
@@ -0,0 +1,64 @@
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+172
View File
@@ -0,0 +1,172 @@
package app
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strconv"
"strings"
)
func normalizeAccessURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || (u.Scheme != "http" && u.Scheme != "https") {
return "", errors.New("访问地址须为无路径的http/https站点地址")
}
host := strings.ToLower(u.Hostname())
port := u.Port()
if port != "" {
n, e := strconv.Atoi(port)
if e != nil || n < 1 || n > 65535 {
return "", errors.New("访问端口须为1至65535")
}
}
if port == "80" && u.Scheme == "http" || port == "443" && u.Scheme == "https" {
port = ""
}
if port != "" {
host = net.JoinHostPort(host, port)
} else if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return u.Scheme + "://" + host, nil
}
func (c Config) accessOrigins() []string {
out := []string{}
for _, raw := range append([]string{c.PublicURL}, c.AccessURLs...) {
base, err := normalizeAccessURL(raw)
if err != nil {
continue
}
found := false
for _, v := range out {
if v == base {
found = true
break
}
}
if !found {
out = append(out, base)
}
}
return out
}
type requestAccessKey struct{}
type requestAccess struct {
base string
httpCookie bool
}
func (a *App) accessFor(r *http.Request) requestAccess {
if state, ok := r.Context().Value(requestAccessKey{}).(requestAccess); ok {
return state
}
return requestAccess{base: strings.TrimRight(a.cfg.PublicURL, "/")}
}
func (a *App) selectAccess(r *http.Request) (requestAccess, error) {
origins := a.cfg.accessOrigins()
allowed := func(raw string) string {
normalized, err := normalizeAccessURL(raw)
if err != nil {
return ""
}
for _, v := range origins {
if v == normalized {
return v
}
}
return ""
}
origin, hint := r.Header.Get("Origin"), r.Header.Get("X-Bluebird-Origin")
selected := ""
if origin != "" {
selected = allowed(origin)
if selected == "" {
return requestAccess{}, errors.New("请求来源不允许")
}
}
if hint != "" {
base := allowed(hint)
if base == "" || selected != "" && base != selected {
return requestAccess{}, errors.New("访问入口不允许")
}
selected = base
}
if selected == "" {
host, scheme := r.Host, ""
if r.TLS != nil {
scheme = "https"
}
peer, _, err := net.SplitHostPort(r.RemoteAddr)
ip := net.ParseIP(peer)
trusted := false
if err == nil {
for _, network := range a.proxies {
if network.Contains(ip) {
trusted = true
break
}
}
}
if trusted {
if forwarded := r.Header.Get("X-Forwarded-Host"); forwarded != "" && !strings.Contains(forwarded, ",") {
host = forwarded
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
scheme = proto
}
}
for _, base := range origins {
u, _ := url.Parse(base)
if strings.EqualFold(u.Host, host) && (scheme == "" || scheme == u.Scheme) {
selected = base
break
}
}
}
if selected == "" {
selected = origins[0]
}
if r.TLS != nil && strings.HasPrefix(selected, "http://") {
return requestAccess{}, errors.New("访问入口协议与连接不一致")
}
hasHTTPS := false
for _, base := range origins {
if strings.HasPrefix(base, "https://") {
hasHTTPS = true
break
}
}
return requestAccess{base: selected, httpCookie: hasHTTPS && strings.HasPrefix(selected, "http://")}, nil
}
func (a *App) withAccess(r *http.Request) (*http.Request, error) {
state, err := a.selectAccess(r)
if err != nil {
return nil, err
}
return r.WithContext(context.WithValue(r.Context(), requestAccessKey{}, state)), nil
}
func cookieNames(r *http.Request, name string) []string {
state, _ := r.Context().Value(requestAccessKey{}).(requestAccess)
if state.httpCookie && (r.Header.Get("Origin") != "" || r.Header.Get("X-Bluebird-Origin") != "") {
return []string{name + "_http", name}
}
return []string{name, name + "_http"}
}
// LANListen accepts only a concrete private or loopback HTTP address.
func LANListen(raw string) (string, string, error) {
base, err := normalizeAccessURL(raw)
if err != nil {
return "", "", err
}
u, _ := url.Parse(base)
ip := net.ParseIP(u.Hostname())
if u.Scheme != "http" || ip == nil || !ip.IsPrivate() && !ip.IsLoopback() || u.Port() == "" {
return "", "", errors.New("内网入口须为http://具体私有IP:端口")
}
return u.Host, base, nil
}
+78
View File
@@ -0,0 +1,78 @@
package app
import (
"bytes"
"os"
"path/filepath"
"syscall"
"github.com/pelletier/go-toml/v2"
)
// UpdateAccessConfig preserves all existing settings, ownership and permissions.
// It never opens the data directory; callers can prepare a candidate while serving.
func UpdateAccessConfig(path, listen string, additions []string, allowLAN bool) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
cfg := DefaultConfig()
if err = toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&cfg); err != nil {
return err
}
if listen != "" {
cfg.Listen = listen
}
cfg.AllowLANHTTP = cfg.AllowLANHTTP || allowLAN
for _, raw := range additions {
base, e := normalizeAccessURL(raw)
if e != nil {
return e
}
found := false
for _, existing := range cfg.accessOrigins() {
if base == existing {
found = true
break
}
}
if !found {
cfg.AccessURLs = append(cfg.AccessURLs, base)
}
}
if err = cfg.Validate(); err != nil {
return err
}
encoded, err := toml.Marshal(cfg)
if err != nil {
return err
}
out, err := os.CreateTemp(filepath.Dir(path), ".access-config-*.toml")
if err != nil {
return err
}
defer os.Remove(out.Name())
defer out.Close()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
if err = out.Chown(int(stat.Uid), int(stat.Gid)); err != nil {
return err
}
}
if err = out.Chmod(info.Mode().Perm()); err != nil {
return err
}
if _, err = out.Write(encoded); err != nil {
return err
}
if err = out.Sync(); err != nil {
return err
}
if err = out.Close(); err != nil {
return err
}
return os.Rename(out.Name(), path)
}
+227
View File
@@ -0,0 +1,227 @@
package app
import (
"bytes"
"encoding/json"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/pelletier/go-toml/v2"
)
func TestMultipleAccessOriginsAndProtocolCookies(t *testing.T) {
c := fixture(t, true)
c.a.cfg.PublicURL = "https://files.example.com"
c.a.cfg.AccessURLs = []string{"http://files.example.com", "http://192.168.1.210:8080"}
c.a.cfg.SecureCookies = true
c.a.cfg.AllowLANHTTP = true
c.a.cfg.Listen = "192.168.1.210:8080"
if err := c.a.cfg.Validate(); err != nil {
t.Fatal(err)
}
jar, _ := cookiejar.New(nil)
request := func(base, method, path string, body any, hint bool) *httptest.ResponseRecorder {
var data []byte
if body != nil {
switch v := body.(type) {
case []byte:
data = v
default:
data, _ = json.Marshal(v)
}
}
r := httptest.NewRequest(method, base+"/api/v1"+path, bytes.NewReader(data))
r.TLS = nil // Real HTTPS is terminated on a separate proxy, which rewrites Host.
r.Host = "192.168.1.210:8080"
if base == "https://files.example.com" {
r.RemoteAddr = "192.168.1.60:1000"
} else if base == "http://files.example.com" {
r.RemoteAddr = "192.168.1.61:1000"
} else {
r.RemoteAddr = "192.168.1.62:1000"
}
if method != "GET" {
r.Header.Set("Origin", base)
}
if hint {
r.Header.Set("X-Bluebird-Origin", base)
}
u, _ := url.Parse(base + "/api/v1" + path)
for _, cookie := range jar.Cookies(u) {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
jar.SetCookies(u, w.Result().Cookies())
return w
}
for _, base := range c.a.cfg.accessOrigins() {
w := request(base, "GET", "/policy", nil, true)
expect(t, w, 200)
var policy map[string]any
json.Unmarshal(w.Body.Bytes(), &policy)
if policy["public_url"] != base {
t.Fatal("policy forced a different origin")
}
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatal("visitor cookie not returned")
}
https := strings.HasPrefix(base, "https:")
if cookies[0].Secure != https {
t.Fatalf("incorrect Secure flag for %s", base)
}
want := "bb_visitor"
if !https {
want += "_http"
}
if cookies[0].Name != want {
t.Fatal("HTTP can overwrite HTTPS cookie")
}
w = request(base, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}, true)
expect(t, w, 200)
if w.Result().Cookies()[0].Secure != https {
t.Fatal("admin cookie protocol incorrect")
}
expect(t, request(base, "GET", "/admin/stats", nil, true), 200)
}
// HTTPS and HTTP on the same public hostname retain independent sessions.
u, _ := url.Parse("http://files.example.com/api/v1/admin/stats")
for _, cookie := range jar.Cookies(u) {
if cookie.Name == "bb_admin" {
t.Fatal("HTTPS credential sent over HTTP")
}
}
payload := []byte("available with failed public DNS")
w := request("http://192.168.1.210:8080", "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "offline.txt", Size: int64(len(payload))}}, Days: 7, Downloads: 8, Renew: true}, true)
expect(t, w, 201)
var parcel struct {
ID string
Files []struct{ ID string }
}
json.Unmarshal(w.Body.Bytes(), &parcel)
expect(t, request("http://192.168.1.210:8080", "PUT", "/uploads/"+parcel.ID+"/files/"+parcel.Files[0].ID, payload, true), 200)
w = request("http://192.168.1.210:8080", "POST", "/uploads/"+parcel.ID+"/publish", map[string]any{}, true)
expect(t, w, 200)
var share struct {
Code, URL string
URLs []map[string]string
}
json.Unmarshal(w.Body.Bytes(), &share)
if !strings.HasPrefix(share.URL, "http://192.168.1.210:8080/") || len(share.URLs) != 3 {
t.Fatal("share did not offer independent LAN/public URLs")
}
for _, base := range c.a.cfg.accessOrigins() {
expect(t, request(base, "POST", "/pickup/resolve", map[string]string{"code": share.Code}, true), 200)
// Navigation downloads have neither Origin nor the frontend access hint.
w = request(base, "GET", "/pickup/files/"+parcel.Files[0].ID+"/download", nil, false)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), payload) {
t.Fatal("cross-entry download failed")
}
}
// The same hostname can hold different pickup grants on HTTP and HTTPS.
other := []byte("a different public HTTP parcel")
w = request("http://files.example.com", "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "other.txt", Size: int64(len(other))}}, Days: 7, Downloads: 4}, true)
expect(t, w, 201)
var second struct {
ID string
Files []struct{ ID string }
}
json.Unmarshal(w.Body.Bytes(), &second)
expect(t, request("http://files.example.com", "PUT", "/uploads/"+second.ID+"/files/"+second.Files[0].ID, other, true), 200)
w = request("http://files.example.com", "POST", "/uploads/"+second.ID+"/publish", map[string]any{}, true)
expect(t, w, 200)
var otherShare struct{ Code string }
json.Unmarshal(w.Body.Bytes(), &otherShare)
expect(t, request("https://files.example.com", "POST", "/pickup/resolve", map[string]string{"code": share.Code}, true), 200)
expect(t, request("http://files.example.com", "POST", "/pickup/resolve", map[string]string{"code": otherShare.Code}, true), 200)
w = request("https://files.example.com", "GET", "/pickup/files/"+parcel.Files[0].ID+"/download", nil, false)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), payload) {
t.Fatal("HTTP grant shadowed the HTTPS grant")
}
w = request("http://files.example.com", "GET", "/pickup/files/"+second.Files[0].ID+"/download", nil, false)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), other) {
t.Fatal("HTTPS grant shadowed the HTTP grant")
}
for _, header := range []struct{ origin, hint string }{{"https://evil.example", ""}, {"", "http://attacker.example"}, {"https://files.example.com", "http://files.example.com"}, {"http://192.168.1.211:8080", ""}} {
r := httptest.NewRequest("POST", "http://192.168.1.210:8080/api/v1/admin/login", strings.NewReader(`{"username":"admin","password":"test-password"}`))
r.Header.Set("Origin", header.origin)
r.Header.Set("X-Bluebird-Origin", header.hint)
out := httptest.NewRecorder()
c.h.ServeHTTP(out, r)
expect(t, out, 403)
}
// Neither Host nor forwarded headers can inject a foreign share URL.
r := httptest.NewRequest("GET", "http://attacker.example/api/v1/policy", nil)
r.RemoteAddr = "203.0.113.4:4000"
r.Header.Set("X-Forwarded-Host", "evil.example")
r.Header.Set("X-Forwarded-Proto", "http")
out := httptest.NewRecorder()
c.h.ServeHTTP(out, r)
expect(t, out, 200)
if strings.Contains(out.Body.String(), "evil.example") || strings.Contains(out.Body.String(), "attacker.example") {
t.Fatal("untrusted forwarding/Host used as site URL")
}
}
func TestAccessConfigurationValidationAndAtomicUpdate(t *testing.T) {
cfg := DefaultConfig()
cfg.PublicURL = "https://files.example.com"
cfg.SecureCookies = true
cfg.AccessURLs = []string{"http://files.example.com", "http://192.168.1.210:8080"}
if cfg.Validate() == nil {
t.Fatal("private HTTP accepted without explicit opt-in")
}
cfg.AllowLANHTTP = true
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
for _, bad := range []string{"null", "https://", "https://:443", "http://user:pass@files.example.com", "http://files.example.com/path", "http://files.example.com?x=1", "ftp://files.example.com", "https://files.example.com:70000"} {
cfg.AccessURLs = []string{bad}
if cfg.Validate() == nil {
t.Fatalf("accepted bad origin %q", bad)
}
}
cfg.AccessURLs = nil
directory := t.TempDir()
path := filepath.Join(directory, "config.toml")
original, _ := toml.Marshal(cfg)
if err := os.WriteFile(path, original, 0640); err != nil {
t.Fatal(err)
}
if err := UpdateAccessConfig(path, "192.168.1.210:8080", []string{"http://files.example.com", "http://192.168.1.210:8080"}, true); err != nil {
t.Fatal(err)
}
saved, err := LoadConfig(path)
if err != nil {
t.Fatal(err)
}
if len(saved.AccessURLs) != 2 || saved.PublicURL != cfg.PublicURL || saved.Encryption != cfg.Encryption || saved.FileBudget != cfg.FileBudget || saved.Listen != "192.168.1.210:8080" {
t.Fatal("access update reset existing settings")
}
if _, err = os.Stat(filepath.Join(directory, "var")); !os.IsNotExist(err) {
t.Fatal("access configuration opened runtime data")
}
updated, _ := os.ReadFile(path)
if err = UpdateAccessConfig(path, "", []string{"http://evil.example/path"}, false); err == nil {
t.Fatal("invalid access update accepted")
}
intact, _ := os.ReadFile(path)
if !bytes.Equal(updated, intact) {
t.Fatal("failed update changed live configuration")
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0640 {
t.Fatal("configuration permissions changed")
}
if _, _, err = LANListen("http://files.example.com:8080"); err == nil {
t.Fatal("LAN flag accepted DNS-dependent domain")
}
}
+247
View File
@@ -0,0 +1,247 @@
package app
import (
"encoding/json"
"golang.org/x/sys/unix"
"net/http"
)
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if !a.isAdmin(r) {
fail(w, 401, "请先登录管理员")
return false
}
return true
}
func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "login", 5) {
fail(w, 429, "登录尝试过于频繁")
return
}
var in struct {
Username string `json:"username"`
Password string `json:"password"`
}
if !decode(w, r, &in) {
return
}
select {
case a.passwordSlots <- struct{}{}:
default:
fail(w, 429, "登录繁忙")
return
}
defer func() { <-a.passwordSlots }()
var username, hash string
e := a.db.QueryRow("SELECT username,password_hash FROM admins WHERE id=1").Scan(&username, &hash)
valid := e == nil && verifyPassword(in.Password, hash) && in.Username == username
if !valid {
a.audit("admin.login_failed", "", a.remoteIP(r))
fail(w, 401, "账号或密码错误")
return
}
a.mu.Lock()
defer a.mu.Unlock()
if _, e = a.db.Exec("DELETE FROM admin_sessions WHERE expires<=? OR token_hash IN(SELECT token_hash FROM admin_sessions ORDER BY expires DESC LIMIT -1 OFFSET 49)", a.now().Unix()); e != nil {
a.serverError(w, e)
return
}
token := randomToken()
_, e = a.db.Exec("INSERT INTO admin_sessions VALUES(?,?)", tokenHash(token), a.now().Unix()+8*3600)
if e != nil {
a.serverError(w, e)
return
}
a.cookie(w, r, "bb_admin", token, "/api/v1/admin", 8*3600)
a.audit("admin.login", "", a.remoteIP(r))
respond(w, 200, map[string]bool{"authenticated": true})
}
func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) {
_, e := a.db.Exec("DELETE FROM admin_sessions WHERE token_hash=?", tokenHash(cookieValue(r, "bb_admin")))
if e != nil {
a.serverError(w, e)
return
}
a.cookie(w, r, "bb_admin", "", "/api/v1/admin", -1)
respond(w, 200, map[string]bool{"authenticated": false})
}
func (a *App) adminStatus(w http.ResponseWriter, r *http.Request) {
respond(w, 200, map[string]bool{"authenticated": a.isAdmin(r)})
}
func (a *App) adminStats(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
used, reserved, meta, e := a.capacity()
if e != nil {
a.serverError(w, e)
return
}
var raw int64
var files, objects, parcels int
queries := []struct {
sql string
dst any
}{{"SELECT COALESCE(sum(size),0) FROM files WHERE object_id IS NOT NULL", &raw}, {"SELECT count(*) FROM files", &files}, {"SELECT count(*) FROM objects", &objects}, {"SELECT count(*) FROM parcels WHERE state='ready'", &parcels}}
for _, q := range queries {
if e = a.db.QueryRow(q.sql).Scan(q.dst); e != nil {
a.serverError(w, e)
return
}
}
limits, e := a.runtimeSettings()
if e != nil {
a.serverError(w, e)
return
}
var disk unix.Statfs_t
if e = unix.Statfs(a.cfg.DataDir, &disk); e != nil {
a.serverError(w, e)
return
}
var auditCount, auditBytes int64
if e = a.db.QueryRow("SELECT records,content_bytes FROM audit_usage WHERE id=1").Scan(&auditCount, &auditBytes); e != nil {
a.serverError(w, e)
return
}
a.transferMu.Lock()
up, down := a.transfers["upload"], a.transfers["download"]
a.transferMu.Unlock()
respond(w, 200, map[string]any{"object_bytes": used, "reserved_bytes": reserved, "metadata_bytes": meta, "logical_bytes": raw, "objects": objects, "files": files, "active_parcels": parcels, "data_budget": limits.DataBudget, "file_budget": limits.FileBudget, "min_free_bytes": limits.MinFreeBytes, "disk_total_bytes": int64(disk.Blocks) * int64(disk.Bsize), "disk_free_bytes": int64(disk.Bavail) * int64(disk.Bsize), "audit_records": auditCount, "audit_content_bytes": auditBytes, "active_uploads": up, "active_downloads": down, "encryption": a.cfg.Encryption, "deduplication": a.cfg.Deduplication})
}
func (a *App) adminDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
res, e := a.db.Exec("UPDATE parcels SET state='revoked',version=version+1 WHERE id=?", id)
if e != nil {
a.serverError(w, e)
return
}
n, _ := res.RowsAffected()
if n == 0 {
fail(w, 404, "包裹不存在")
return
}
a.audit("admin.delete", id, a.remoteIP(r))
respond(w, 200, map[string]any{"revoked": true, "message": "已禁止新下载,实体文件由清理任务回收"})
}
func (a *App) adminPolicy(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
if r.Method == "GET" {
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
respond(w, 200, p)
return
}
var p Policy
if !decode(w, r, &p) {
return
}
if e := p.Validate(); e != nil {
fail(w, 400, e.Error())
return
}
a.mu.Lock()
defer a.mu.Unlock()
b, e := json.Marshal(p)
if e == nil {
_, e = a.db.Exec("UPDATE settings SET json=? WHERE id=1", string(b))
}
if e != nil {
a.serverError(w, e)
return
}
a.audit("policy.update", "", a.remoteIP(r))
respond(w, 200, p)
}
func (a *App) adminCleanup(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
if e := a.Cleanup(); e != nil {
a.serverError(w, e)
return
}
a.audit("admin.cleanup", "", a.remoteIP(r))
respond(w, 200, map[string]bool{"cleaned": true})
}
func (a *App) adminDeleteFile(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
fid := r.PathValue("file")
res, e := a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", fid)
if e != nil {
a.serverError(w, e)
return
}
n, _ := res.RowsAffected()
if n == 0 {
fail(w, 404, "文件不存在")
return
}
a.audit("admin.file_delete", fid, a.remoteIP(r))
respond(w, 200, map[string]bool{"deleted": true})
}
func (a *App) adminReissue(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
var state string
if e := a.db.QueryRow("SELECT state FROM parcels WHERE id=?", id).Scan(&state); e != nil || state != "restored" {
fail(w, 409, "仅允许重新发布恢复待审核包裹")
return
}
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
now := a.now().Unix()
expiry := now + int64(p.DefaultDays)*86400
hard := now + int64(p.MaxLifetimeDays)*86400
code := pickupCode()
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
res, e := tx.Exec("UPDATE files SET status='ready',remaining=?,expires=?,hard_expires=? WHERE parcel=? AND object_id IS NOT NULL AND status NOT IN('missing','deleted','deleting')", p.DefaultDownloads, expiry, hard, id)
if e != nil {
a.serverError(w, e)
return
}
n, _ := res.RowsAffected()
if n == 0 {
fail(w, 409, "没有可重新发布的文件")
return
}
_, e = tx.Exec("UPDATE parcels SET state='ready',created=?,expires=?,hard_expires=?,code_hash=?,version=version+1,renewal_days=?,renewal_mode=? WHERE id=?", now, expiry, hard, a.keyed(code), p.RenewalDays, p.RenewalMode, id)
if e == nil {
e = tx.Commit()
}
if e != nil {
a.serverError(w, e)
return
}
a.audit("admin.reissue", id, a.remoteIP(r))
respond(w, 200, a.shareResult(r, code, expiry))
}
+216
View File
@@ -0,0 +1,216 @@
package app
import (
"database/sql"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
)
type adminListResult struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Pages int `json:"pages"`
}
func adminPaging(q url.Values, defaultSize int) (adminListResult, error) {
p := adminListResult{Items: []map[string]any{}, Page: 1, PageSize: defaultSize, Pages: 1}
for key, dest := range map[string]*int{"page": &p.Page, "page_size": &p.PageSize} {
if raw := q.Get(key); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || (key == "page_size" && n > 100) {
return p, errors.New("页码须为正整数,每页条数须为1至100")
}
*dest = n
}
}
return p, nil
}
func (p *adminListResult) setTotal(total int) {
p.Total = total
p.Pages = max(1, (total+p.PageSize-1)/p.PageSize)
p.Page = min(p.Page, p.Pages)
}
func adminFilter(q url.Values, key string, limit int) (string, error) {
v := strings.TrimSpace(q.Get(key))
if len(v) > limit {
return "", errors.New("搜索条件过长")
}
return v, nil
}
func (a *App) adminParcels(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
q := r.URL.Query()
result, err := adminPaging(q, 10)
if err != nil {
fail(w, 400, err.Error())
return
}
keyword, err := adminFilter(q, "q", 256)
if err != nil {
fail(w, 400, err.Error())
return
}
state, err := adminFilter(q, "state", 32)
if err != nil {
fail(w, 400, err.Error())
return
}
if state != "" && state != "uploading" && state != "ready" && state != "revoked" && state != "expired" && state != "exhausted" && state != "restored" {
fail(w, 400, "包裹状态无效")
return
}
where := " WHERE 1=1"
args := []any{}
if keyword != "" {
where += " AND (instr(lower(p.id),lower(?))>0 OR EXISTS(SELECT 1 FROM files f WHERE f.parcel=p.id AND instr(lower(f.name),lower(?))>0))"
args = append(args, keyword, keyword)
}
if state != "" {
where += " AND p.state=?"
args = append(args, state)
}
// File details use the same connection after the rows are closed. Prevent
// cleanup/publication from changing the selected parcels between those reads.
a.mu.Lock()
defer a.mu.Unlock()
var total int
if err = a.db.QueryRowContext(r.Context(), "SELECT count(*) FROM parcels p"+where, args...).Scan(&total); err != nil {
a.serverError(w, err)
return
}
result.setTotal(total)
listArgs := append(append([]any{}, args...), result.PageSize, (result.Page-1)*result.PageSize)
rows, err := a.db.QueryContext(r.Context(), "SELECT p.id,p.state,p.ip,p.created,p.expires,p.renewal_days,p.renewal_mode,(SELECT count(*) FROM files WHERE parcel=p.id),(SELECT COALESCE(sum(size),0) FROM files WHERE parcel=p.id) FROM parcels p"+where+" ORDER BY p.created DESC,p.id DESC LIMIT ? OFFSET ?", listArgs...)
if err != nil {
a.serverError(w, err)
return
}
for rows.Next() {
var id, state, ip, mode string
var created, expiry, size int64
var renewal, count int
if err = rows.Scan(&id, &state, &ip, &created, &expiry, &renewal, &mode, &count, &size); err != nil {
rows.Close()
a.serverError(w, err)
return
}
result.Items = append(result.Items, map[string]any{"id": id, "state": state, "ip": ip, "created": created, "expires": expiry, "renewal_days": renewal, "renewal_mode": mode, "count": count, "size": size})
}
err = rows.Err()
rows.Close()
if err != nil {
a.serverError(w, err)
return
}
for _, p := range result.Items {
files, err := a.fileList(p["id"].(string))
if err != nil {
a.serverError(w, err)
return
}
p["files"] = files
}
respond(w, 200, result)
}
func (a *App) adminAudit(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
q := r.URL.Query()
result, err := adminPaging(q, 20)
if err != nil {
fail(w, 400, err.Error())
return
}
where := " WHERE 1=1"
args := []any{}
for _, f := range []struct {
key string
limit int
}{{"action", 64}, {"source", 128}, {"target", 256}} {
value, err := adminFilter(q, f.key, f.limit)
if err != nil {
fail(w, 400, err.Error())
return
}
if value == "" {
continue
}
if f.key == "action" {
where += " AND action=?"
} else {
where += " AND instr(lower(" + f.key + "),lower(?))>0"
}
args = append(args, value)
}
var from, to int64
for _, bound := range []struct {
key, op string
dest *int64
}{{"from", ">=", &from}, {"to", "<=", &to}} {
if raw := q.Get(bound.key); raw != "" {
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
fail(w, 400, "时间须为非负Unix秒数")
return
}
*bound.dest = value
where += " AND created" + bound.op + "?"
args = append(args, value)
}
}
if q.Get("from") != "" && q.Get("to") != "" && from > to {
fail(w, 400, "开始时间不能晚于结束时间")
return
}
// Count and items share a snapshot so concurrent audit writes or retention do
// not change the response's total and page boundaries mid-query.
tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{ReadOnly: true})
if err != nil {
a.serverError(w, err)
return
}
defer tx.Rollback()
var total int
if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM audit"+where, args...).Scan(&total); err != nil {
a.serverError(w, err)
return
}
result.setTotal(total)
listArgs := append(append([]any{}, args...), result.PageSize, (result.Page-1)*result.PageSize)
rows, err := tx.QueryContext(r.Context(), "SELECT id,created,action,target,source FROM audit"+where+" ORDER BY id DESC LIMIT ? OFFSET ?", listArgs...)
if err != nil {
a.serverError(w, err)
return
}
for rows.Next() {
var id, created int64
var action, target, source string
if err = rows.Scan(&id, &created, &action, &target, &source); err != nil {
rows.Close()
a.serverError(w, err)
return
}
result.Items = append(result.Items, map[string]any{"id": id, "created": created, "action": action, "target": target, "source": source})
}
err = rows.Err()
rows.Close()
if err != nil {
a.serverError(w, err)
return
}
if err = tx.Commit(); err != nil {
a.serverError(w, err)
return
}
respond(w, 200, result)
}
+175
View File
@@ -0,0 +1,175 @@
package app
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"testing"
)
func adminResult(t *testing.T, c *testClient, path string) adminListResult {
t.Helper()
w := c.request(t, "GET", path, nil)
expect(t, w, 200)
var p adminListResult
if err := json.Unmarshal(w.Body.Bytes(), &p); err != nil {
t.Fatal(err)
}
if p.Items == nil {
t.Fatal("items must be an array, including when empty")
}
return p
}
func TestAdminParcelPaginationAndSearch(t *testing.T) {
c := fixture(t, false)
expect(t, c.request(t, "GET", "/admin/parcels?page_size=10", nil), 401)
loginTestAdmin(t, c)
tx, err := c.a.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
for i := 1; i <= 25; i++ {
id := fmt.Sprintf("%032x", i)
state := "ready"
if i%2 == 0 {
state = "revoked"
}
_, err = tx.Exec("INSERT INTO parcels(id,visitor,ip,state,created,password_hash,manage_hash,days,downloads,renewal_days,renewal_mode,lifetime_days,upload_expires,usage_day) VALUES(?,?,'203.0.113.1',?,1000,'','',1,1,0,'add',30,2000,'2026-10-05')", id, "visitor", state)
if err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("document-%d.txt", i)
if i == 5 {
name = "说明_%_Report.txt"
}
_, err = tx.Exec("INSERT INTO files(id,parcel,name,size,status,reserved,remaining) VALUES(?,?,?,12,'deleted',0,0)", id, id, name)
if err != nil {
t.Fatal(err)
}
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
first := adminResult(t, c, "/admin/parcels")
if first.Total != 25 || first.PageSize != 10 || first.Pages != 3 || len(first.Items) != 10 || first.Items[0]["id"] != fmt.Sprintf("%032x", 25) {
t.Fatalf("bad first page: %+v", first)
}
second := adminResult(t, c, "/admin/parcels?page=2")
if second.Page != 2 || second.Items[0]["id"] != fmt.Sprintf("%032x", 15) {
t.Fatalf("bad second page: %+v", second)
}
last := adminResult(t, c, "/admin/parcels?page=999999999")
if last.Page != 3 || len(last.Items) != 5 {
t.Fatalf("out-of-range page was not clamped: %+v", last)
}
for _, q := range []string{"REPORT", "说明", "%", fmt.Sprintf("%032x", 5)} {
p := adminResult(t, c, "/admin/parcels?q="+url.QueryEscape(q))
if p.Total != 1 || p.Items[0]["id"] != fmt.Sprintf("%032x", 5) {
t.Fatalf("literal filename/ID query %q: %+v", q, p)
}
}
p := adminResult(t, c, "/admin/parcels?state=revoked&page_size=20")
if p.Total != 12 || p.PageSize != 20 || len(p.Items) != 12 {
t.Fatalf("state filter: %+v", p)
}
p = adminResult(t, c, "/admin/parcels?q="+url.QueryEscape("' OR 1=1 --"))
if p.Total != 0 || len(p.Items) != 0 || p.Page != 1 || p.Pages != 1 {
t.Fatalf("empty/literal injection query: %+v", p)
}
for _, q := range []string{"page=0", "page=-1", "page=oops", "page_size=0", "page_size=101", "page_size=oops", "state=unknown", "q=" + strings.Repeat("a", 257)} {
expect(t, c.request(t, "GET", "/admin/parcels?"+q, nil), 400)
}
}
func TestAdminAuditPaginationAndCombinedFilters(t *testing.T) {
c := fixture(t, false)
expect(t, c.request(t, "GET", "/admin/audit", nil), 401)
loginTestAdmin(t, c)
if _, err := c.a.db.Exec("DELETE FROM audit"); err != nil {
t.Fatal(err)
}
base := c.a.now().Unix()
tx, err := c.a.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
for i := 1; i <= 54; i++ {
action := "upload.create"
if i%2 == 0 {
action = "admin.delete"
}
source := "2001:db8::1"
if i%3 == 0 {
source = "203.0.113.1"
}
target := fmt.Sprintf("job-%d", i)
if i == 18 {
target = "job_%_18"
}
if _, err = tx.Exec("INSERT INTO audit(created,action,target,source) VALUES(?,?,?,?)", base+int64(i)*60, action, target, source); err != nil {
t.Fatal(err)
}
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
p := adminResult(t, c, "/admin/audit")
if p.Total != 54 || p.PageSize != 20 || p.Pages != 3 || len(p.Items) != 20 || p.Items[0]["target"] != "job-54" {
t.Fatalf("bad audit page: %+v", p)
}
p2 := adminResult(t, c, "/admin/audit?page=2")
if p2.Items[0]["target"] != "job-34" {
t.Fatalf("audit page did not change: %+v", p2)
}
last := adminResult(t, c, "/admin/audit?page=99999")
if last.Page != 3 || len(last.Items) != 14 {
t.Fatalf("bad audit last page: %+v", last)
}
query := fmt.Sprintf("/admin/audit?action=admin.delete&source=203.0.113.1&target=JOB&from=%d&to=%d", base+10*60, base+30*60)
filtered := adminResult(t, c, query)
if filtered.Total != 4 || len(filtered.Items) != 4 {
t.Fatalf("combined/inclusive date filters: %+v", filtered)
}
for _, item := range filtered.Items {
if item["action"] != "admin.delete" || item["source"] != "203.0.113.1" {
t.Fatalf("wrong filter result: %+v", item)
}
}
literal := adminResult(t, c, "/admin/audit?target=%25")
if literal.Total != 1 || literal.Items[0]["target"] != "job_%_18" {
t.Fatalf("wildcard interpreted as pattern: %+v", literal)
}
ipv6 := adminResult(t, c, "/admin/audit?source=2001%3ADB8")
if ipv6.Total != 36 {
t.Fatalf("IPv6 fragment filter: %+v", ipv6)
}
empty := adminResult(t, c, "/admin/audit?action=unknown")
if empty.Total != 0 || empty.Pages != 1 || len(empty.Items) != 0 {
t.Fatal("empty audit search malformed")
}
for _, q := range []string{"page=0", "page_size=101", "from=-1", "from=nope", "to=1.5", "from=20&to=10", "action=" + strings.Repeat("a", 65), "source=" + strings.Repeat("a", 129), "target=" + strings.Repeat("a", 257)} {
expect(t, c.request(t, "GET", "/admin/audit?"+q, nil), 400)
}
}
func TestAdminAuditOneHundredThousandRecordsRemainPaged(t *testing.T) {
c := fixture(t, false)
loginTestAdmin(t, c)
_, err := c.a.db.Exec("DELETE FROM audit; WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<100000) INSERT INTO audit(created,action,target,source) SELECT 1000,'upload.create',printf('target-%d',i),'203.0.113.1' FROM n")
if err != nil {
t.Fatal(err)
}
first := adminResult(t, c, "/admin/audit")
if first.Total != 100000 || first.Pages != 5000 || len(first.Items) != 20 || first.Items[0]["target"] != "target-100000" {
t.Fatalf("large audit query unbounded: %+v", first)
}
last := adminResult(t, c, "/admin/audit?page=5000")
if last.Page != 5000 || len(last.Items) != 20 || last.Items[19]["target"] != "target-1" {
t.Fatalf("large audit final page incorrect: %+v", last)
}
filtered := adminResult(t, c, "/admin/audit?target=target-12345")
if filtered.Total != 1 || len(filtered.Items) != 1 || filtered.Items[0]["target"] != "target-12345" {
t.Fatal("search did not include older/all retained audit events")
}
}
+471
View File
@@ -0,0 +1,471 @@
package app
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"filippo.io/age"
"golang.org/x/sys/unix"
_ "modernc.org/sqlite"
)
type App struct {
requestWG sync.WaitGroup
cfg Config
db *sql.DB
root *os.Root
lock *os.File
secret []byte
identity *age.X25519Identity
mu sync.Mutex
passwordSlots chan struct{}
rateMu sync.Mutex
rates map[string]*rateEntry
transferMu sync.Mutex
transfers map[string]int
activeFiles map[string]bool
activeObjects map[string]int
extraDisk int64
proxies []*net.IPNet
now func() time.Time
log *slog.Logger
}
const schema = `
CREATE TABLE IF NOT EXISTS schema_version(version INTEGER NOT NULL);
INSERT INTO schema_version SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM schema_version);
CREATE TABLE IF NOT EXISTS settings(id INTEGER PRIMARY KEY CHECK(id=1),json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS runtime_settings(id INTEGER PRIMARY KEY CHECK(id=1),json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS admins(id INTEGER PRIMARY KEY CHECK(id=1),username TEXT NOT NULL,password_hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS admin_sessions(token_hash TEXT PRIMARY KEY,expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS visitors(id TEXT PRIMARY KEY,token_hash TEXT UNIQUE NOT NULL,expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS parcels(id TEXT PRIMARY KEY,visitor TEXT NOT NULL,ip TEXT NOT NULL,state TEXT NOT NULL,created INTEGER NOT NULL,expires INTEGER NOT NULL DEFAULT 0,hard_expires INTEGER NOT NULL DEFAULT 0,code_hash TEXT UNIQUE,password_hash TEXT NOT NULL,manage_hash TEXT NOT NULL,version INTEGER NOT NULL DEFAULT 1,days INTEGER NOT NULL,downloads INTEGER NOT NULL,renewal_days INTEGER NOT NULL,renewal_mode TEXT NOT NULL,lifetime_days INTEGER NOT NULL,upload_expires INTEGER NOT NULL,usage_day TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS objects(id TEXT PRIMARY KEY,digest TEXT UNIQUE NOT NULL,size INTEGER NOT NULL,stored_bytes INTEGER NOT NULL,encrypted INTEGER NOT NULL,key_id TEXT NOT NULL,created INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS files(id TEXT PRIMARY KEY,parcel TEXT NOT NULL REFERENCES parcels(id) ON DELETE CASCADE,name TEXT NOT NULL,size INTEGER NOT NULL,status TEXT NOT NULL,object_id TEXT REFERENCES objects(id),reserved INTEGER NOT NULL,remaining INTEGER NOT NULL,received INTEGER NOT NULL DEFAULT 0,charged INTEGER NOT NULL DEFAULT 0,attempts INTEGER NOT NULL DEFAULT 0,expires INTEGER NOT NULL DEFAULT 0,hard_expires INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS grants(token_hash TEXT PRIMARY KEY,parcel TEXT NOT NULL REFERENCES parcels(id) ON DELETE CASCADE,version INTEGER NOT NULL,expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS usage(subject TEXT NOT NULL,day TEXT NOT NULL,bytes INTEGER NOT NULL,uploads INTEGER NOT NULL,PRIMARY KEY(subject,day));
CREATE TABLE IF NOT EXISTS downloads(id TEXT PRIMARY KEY,file_id TEXT NOT NULL,created INTEGER NOT NULL,started INTEGER NOT NULL DEFAULT 0,completed INTEGER NOT NULL DEFAULT 0,refunded INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY AUTOINCREMENT,created INTEGER NOT NULL,action TEXT NOT NULL,target TEXT NOT NULL,source TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS audit_created ON audit(created);
CREATE INDEX IF NOT EXISTS audit_action_id ON audit(action,id DESC);
CREATE INDEX IF NOT EXISTS parcels_created_id ON parcels(created DESC,id DESC);
CREATE INDEX IF NOT EXISTS files_parcel ON files(parcel);
CREATE INDEX IF NOT EXISTS files_object ON files(object_id);
CREATE INDEX IF NOT EXISTS parcels_expiry ON parcels(state,expires);
CREATE INDEX IF NOT EXISTS parcels_visitor ON parcels(visitor);
`
func Open(c Config) (*App, error) { return open(c, false) }
func OpenForRestore(c Config) (*App, error) { return open(c, true) }
func open(c Config, restoring bool) (*App, error) {
if e := c.Validate(); e != nil {
return nil, e
}
if e := os.MkdirAll(c.DataDir, 0700); e != nil {
return nil, e
}
root, e := os.OpenRoot(c.DataDir)
if e != nil {
return nil, e
}
if !restoring {
if _, me := root.Stat("restore.incomplete"); me == nil {
root.Close()
return nil, errors.New("恢复未完成,拒绝启动;请检查恢复错误并重新恢复到空目录")
}
}
a := &App{cfg: c, root: root, now: time.Now, log: slog.Default(), passwordSlots: make(chan struct{}, 2), rates: map[string]*rateEntry{}, transfers: map[string]int{}, activeFiles: map[string]bool{}, activeObjects: map[string]int{}}
fail := func(e error) (*App, error) { a.Close(); return nil, e }
for _, d := range []string{"db", "objects", "tmp", "secrets", "quarantine"} {
if e = root.Mkdir(d, 0700); e != nil && !errors.Is(e, os.ErrExist) {
return fail(e)
}
st, se := root.Lstat(d)
if se != nil {
return fail(se)
}
if !st.IsDir() || st.Mode()&os.ModeSymlink != 0 {
return fail(errors.New("数据子目录必须为真实目录"))
}
}
a.lock, e = root.OpenFile("service.lock", os.O_CREATE|os.O_RDWR, 0600)
if e != nil {
return fail(e)
}
if e = unix.Flock(int(a.lock.Fd()), unix.LOCK_EX|unix.LOCK_NB); e != nil {
return fail(errors.New("数据目录已被另一进程使用"))
}
secretPath := "secrets/hmac.key"
if _, de := root.Stat("db/bluebird.db"); de == nil {
if _, se := root.Lstat(secretPath); se != nil {
return fail(errors.New("既有数据库缺少 HMAC 密钥"))
}
}
a.secret, e = readOrCreate(root, secretPath, func() ([]byte, error) { return []byte(randomToken()), nil })
if e != nil {
return fail(e)
}
if len(a.secret) < 32 {
return fail(errors.New("HMAC 密钥损坏"))
}
if st, se := root.Lstat("secrets/age.key"); se == nil && !st.Mode().IsRegular() {
return fail(errors.New("加密私钥必须为普通文件"))
}
key, e := root.ReadFile("secrets/age.key")
if e == nil {
a.identity, e = age.ParseX25519Identity(strings.TrimSpace(string(key)))
if e != nil {
return fail(e)
}
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
for _, path := range []string{"db/bluebird.db", "db/bluebird.db-wal", "db/bluebird.db-shm", "service.lock"} {
if st, se := root.Lstat(path); se == nil && !st.Mode().IsRegular() {
return fail(errors.New("数据库和锁文件必须为普通文件"))
}
}
dbPath := filepath.Join(c.DataDir, "db", "bluebird.db")
if st, se := root.Lstat("db/bluebird.db"); se == nil && st.Mode()&os.ModeSymlink != 0 {
return fail(errors.New("禁止数据库符号链接"))
}
a.db, e = sql.Open("sqlite", dbPath)
if e != nil {
return fail(e)
}
a.db.SetMaxOpenConns(1)
if _, e = a.db.Exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; PRAGMA temp_store=MEMORY; " + schema); e != nil {
return fail(e)
}
var version int
if e = a.db.QueryRow("SELECT version FROM schema_version").Scan(&version); e != nil || version != 1 {
return fail(errors.New("不支持的数据库版本"))
}
_ = root.Chmod("db/bluebird.db", 0600)
if a.identity == nil {
var n int
if e = a.db.QueryRow("SELECT count(*) FROM objects WHERE encrypted=1").Scan(&n); e != nil {
return fail(e)
}
if n > 0 {
return fail(errors.New("加密私钥丢失,拒绝生成替代密钥"))
}
if c.Encryption {
b, e := readOrCreate(root, "secrets/age.key", func() ([]byte, error) {
key, e := age.GenerateX25519Identity()
if e != nil {
return nil, e
}
return []byte(key.String() + "\n"), nil
})
if e != nil {
return fail(e)
}
a.identity, e = age.ParseX25519Identity(strings.TrimSpace(string(b)))
if e != nil {
return fail(e)
}
}
}
b, e := json.Marshal(c.Policy)
if e != nil {
return fail(e)
}
if _, e = a.db.Exec("INSERT OR IGNORE INTO settings VALUES(1,?)", string(b)); e != nil {
return fail(e)
}
if _, e = a.policy(); e != nil {
return fail(e)
}
b, e = json.Marshal(c.InitialRuntimeSettings())
if e != nil {
return fail(e)
}
if _, e = a.db.Exec("INSERT OR IGNORE INTO runtime_settings VALUES(1,?)", string(b)); e != nil {
return fail(e)
}
if _, e = a.runtimeSettings(); e != nil {
return fail(e)
}
if e = a.initializeAuditUsage(); e != nil {
return fail(e)
}
for _, p := range c.TrustedProxies {
_, n, _ := net.ParseCIDR(p)
a.proxies = append(a.proxies, n)
}
if !restoring {
if e = a.recover(); e != nil {
return fail(e)
}
}
return a, nil
}
func readOrCreate(root *os.Root, path string, generate func() ([]byte, error)) ([]byte, error) {
if st, e := root.Lstat(path); e == nil && !st.Mode().IsRegular() {
return nil, errors.New("密钥必须为普通文件")
}
b, e := root.ReadFile(path)
if e == nil {
return b, nil
}
if !errors.Is(e, os.ErrNotExist) {
return nil, e
}
b, e = generate()
if e != nil {
return nil, e
}
f, e := root.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
return nil, e
}
_, e = f.Write(b)
if e == nil {
e = f.Sync()
}
ce := f.Close()
if e == nil {
e = ce
}
if e == nil {
e = syncDir(root, filepath.Dir(path))
}
return b, e
}
func syncDir(root *os.Root, path string) error {
f, e := root.Open(path)
if e != nil {
return e
}
defer f.Close()
return f.Sync()
}
func (a *App) WaitRequests() { a.requestWG.Wait() }
func (a *App) Close() error {
var e error
if a.db != nil {
e = a.db.Close()
}
if a.root != nil {
a.root.Close()
}
if a.lock != nil {
a.lock.Close()
}
return e
}
func (a *App) policy() (Policy, error) {
var s string
p := Policy{}
e := a.db.QueryRow("SELECT json FROM settings WHERE id=1").Scan(&s)
if e == nil {
e = json.Unmarshal([]byte(s), &p)
}
if e == nil {
e = p.Validate()
}
return p, e
}
func (a *App) audit(action, target, source string) {
tx, e := a.db.Begin()
if e != nil {
a.log.Error("audit failed", "error", e)
return
}
defer tx.Rollback()
_, e = tx.Exec("INSERT INTO audit(created,action,target,source) VALUES(?,?,?,?)", a.now().Unix(), action, target, source)
if e == nil {
e = a.trimAuditTx(tx)
}
if e == nil {
e = tx.Commit()
}
if e != nil {
a.log.Error("audit failed", "action", action, "error", e)
}
}
func (a *App) SetAdmin(username, password string) error {
if len(username) < 1 || len(username) > 64 {
return errors.New("管理员名称范围错误")
}
h, e := hashPassword(password)
if e != nil {
return e
}
tx, e := a.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e = tx.Exec("INSERT INTO admins VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET username=excluded.username,password_hash=excluded.password_hash", username, h); e != nil {
return e
}
if _, e = tx.Exec("DELETE FROM admin_sessions"); e != nil {
return e
}
return tx.Commit()
}
func (a *App) Initialized() bool {
var n int
a.db.QueryRow("SELECT count(*) FROM admins").Scan(&n)
return n == 1
}
func (a *App) Handler(static http.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/health", a.health)
mux.HandleFunc("GET /api/v1/policy", a.publicPolicy)
mux.HandleFunc("POST /api/v1/uploads", a.createUpload)
mux.HandleFunc("PUT /api/v1/uploads/{parcel}/files/{file}", a.uploadFile)
mux.HandleFunc("POST /api/v1/uploads/{parcel}/publish", a.publish)
mux.HandleFunc("GET /api/v1/uploads/{parcel}", a.uploadStatus)
mux.HandleFunc("GET /api/v1/mine", a.mine)
mux.HandleFunc("POST /api/v1/parcels/{parcel}/revoke", a.revoke)
mux.HandleFunc("POST /api/v1/parcels/{parcel}/code", a.regenerate)
mux.HandleFunc("POST /api/v1/pickup/resolve", a.resolve)
mux.HandleFunc("GET /api/v1/pickup", a.pickupHandler)
mux.HandleFunc("GET /api/v1/pickup/files/{file}/download", a.download)
mux.HandleFunc("POST /api/v1/admin/login", a.adminLogin)
mux.HandleFunc("POST /api/v1/admin/logout", a.adminLogout)
mux.HandleFunc("GET /api/v1/admin/status", a.adminStatus)
mux.HandleFunc("GET /api/v1/admin/stats", a.adminStats)
mux.HandleFunc("GET /api/v1/admin/runtime", a.adminRuntime)
mux.HandleFunc("PUT /api/v1/admin/runtime", a.adminRuntime)
mux.HandleFunc("GET /api/v1/admin/parcels", a.adminParcels)
mux.HandleFunc("POST /api/v1/admin/parcels/{parcel}/delete", a.adminDelete)
mux.HandleFunc("GET /api/v1/admin/policy", a.adminPolicy)
mux.HandleFunc("PUT /api/v1/admin/policy", a.adminPolicy)
mux.HandleFunc("GET /api/v1/admin/audit", a.adminAudit)
mux.HandleFunc("POST /api/v1/admin/cleanup", a.adminCleanup)
mux.HandleFunc("POST /api/v1/admin/files/{file}/delete", a.adminDeleteFile)
mux.HandleFunc("POST /api/v1/admin/parcels/{parcel}/reissue", a.adminReissue)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { fail(w, 404, "接口不存在") })
mux.Handle("/", static)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.requestWG.Add(1)
defer a.requestWG.Done()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Cache-Control", "no-store")
}
if strings.HasPrefix(r.URL.Path, "/api/") {
var e error
r, e = a.withAccess(r)
if e != nil {
fail(w, 403, e.Error())
return
}
}
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
origin := r.Header.Get("Origin")
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
fail(w, 403, "跨站请求被拒绝")
return
}
if origin == "" && strings.Contains(r.Header.Get("Cookie"), "bb_") {
fail(w, 403, "携带会话的修改请求必须有 Origin")
return
}
}
if !a.Initialized() && r.URL.Path != "/api/v1/health" {
fail(w, 503, "请先在服务器上初始化管理员")
return
}
mux.ServeHTTP(w, r)
})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if e := d.Decode(v); e != nil {
fail(w, 400, "请求格式无效")
return false
}
if e := d.Decode(&struct{}{}); e != io.EOF {
fail(w, 400, "请求包含多余内容")
return false
}
return true
}
func respond(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, status int, message string) {
respond(w, status, map[string]string{"error": message})
}
func (a *App) serverError(w http.ResponseWriter, e error) {
a.log.Error("operation failed", "error", e)
fail(w, 500, "服务器操作失败,请稍后重试")
}
func (a *App) health(w http.ResponseWriter, r *http.Request) {
if e := a.db.Ping(); e != nil {
fail(w, 503, "数据库不可用")
return
}
respond(w, 200, map[string]any{"ready": a.Initialized(), "version": "0.1.0"})
}
func (a *App) publicPolicy(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "policy", 60) {
fail(w, 429, "访问过于频繁")
return
}
a.mu.Lock()
defer a.mu.Unlock()
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
visitor, e := a.visitor(w, r)
if e != nil {
fail(w, 429, "匿名会话暂不可用")
return
}
var visitorExpires int64
if e = a.db.QueryRow("SELECT expires FROM visitors WHERE id=?", visitor).Scan(&visitorExpires); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, map[string]any{"policy": p, "public_url": a.accessFor(r).base, "access_urls": a.cfg.accessOrigins(), "encryption": a.cfg.Encryption, "deduplication": a.cfg.Deduplication, "visitor_expires": visitorExpires})
}
func (a *App) RunMaintenance(stop <-chan struct{}) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
if e := a.Cleanup(); e != nil {
a.log.Error("cleanup failed", "error", e)
}
}
}
}
func (a *App) Check() error {
var result string
if e := a.db.QueryRow("PRAGMA integrity_check").Scan(&result); e != nil {
return e
}
if result != "ok" {
return fmt.Errorf("SQLite: %s", result)
}
return a.verifyObjects()
}
+790
View File
@@ -0,0 +1,790 @@
package app
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"hash"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"golang.org/x/time/rate"
)
type testClient struct {
a *App
h http.Handler
cookies map[string]*http.Cookie
ip string
}
func fixture(t *testing.T, encrypted bool) *testClient {
t.Helper()
c := DefaultConfig()
c.DataDir = t.TempDir()
c.Encryption = encrypted
c.MinFreeBytes = 0
c.DataBudget = 128 << 20
c.FileBudget = 64 << 20
a, e := Open(c)
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { a.Close() })
if e = a.SetAdmin("admin", "test-password"); e != nil {
t.Fatal(e)
}
return &testClient{a: a, h: a.Handler(http.NotFoundHandler()), cookies: map[string]*http.Cookie{}, ip: "203.0.113.10"}
}
func (c *testClient) request(t *testing.T, method, path string, body []byte) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, "http://localhost:8080/api/v1"+path, bytes.NewReader(body))
r.RemoteAddr = c.ip + ":1234"
if method != "GET" && method != "HEAD" {
r.Header.Set("Origin", "http://localhost:8080")
}
for _, cookie := range c.cookies {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
for _, cookie := range w.Result().Cookies() {
if cookie.MaxAge < 0 {
delete(c.cookies, cookie.Name)
} else {
c.cookies[cookie.Name] = cookie
}
}
return w
}
func (c *testClient) json(t *testing.T, method, path string, v any) *httptest.ResponseRecorder {
b, e := json.Marshal(v)
if e != nil {
t.Fatal(e)
}
return c.request(t, method, path, b)
}
func expect(t *testing.T, w *httptest.ResponseRecorder, status int) {
t.Helper()
if w.Code != status {
t.Fatalf("status=%d want=%d body=%s", w.Code, status, w.Body.String())
}
}
func (c *testClient) create(t *testing.T, names []string, data [][]byte, downloads int, renew bool) (string, []string) {
t.Helper()
fs := []ManifestFile{}
for i, d := range data {
fs = append(fs, ManifestFile{Name: names[i], Size: int64(len(d))})
}
w := c.json(t, "POST", "/uploads", Manifest{Files: fs, Days: 7, Downloads: downloads, Renew: renew})
expect(t, w, 201)
var result struct {
ID string `json:"id"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if e := json.Unmarshal(w.Body.Bytes(), &result); e != nil {
t.Fatal(e)
}
ids := []string{}
for _, f := range result.Files {
ids = append(ids, f.ID)
}
return result.ID, ids
}
func (c *testClient) publish(t *testing.T, id string) string {
w := c.json(t, "POST", "/uploads/"+id+"/publish", struct{}{})
expect(t, w, 200)
var out struct {
Code string `json:"code"`
}
json.Unmarshal(w.Body.Bytes(), &out)
if len(out.Code) != 10 {
t.Fatal("missing code")
}
return out.Code
}
func (c *testClient) resolve(t *testing.T, code string) {
w := c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code})
expect(t, w, 200)
}
func (c *testClient) upload(t *testing.T, id, fid string, b []byte) {
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+fid, b), 200)
}
func scalar(t *testing.T, a *App, q string, args ...any) int64 {
t.Helper()
var n int64
if e := a.db.QueryRow(q, args...).Scan(&n); e != nil {
t.Fatal(e)
}
return n
}
func TestDedupIndependentPermissionsAndCleanup(t *testing.T) {
for _, encrypted := range []bool{false, true} {
t.Run(map[bool]string{false: "plain", true: "age"}[encrypted], func(t *testing.T) {
c := fixture(t, encrypted)
data := []byte(strings.Repeat("confidential-content", 1000))
id, files := c.create(t, []string{"a.txt", "b.txt"}, [][]byte{data, data}, 3, true)
for _, f := range files {
c.upload(t, id, f, data)
}
code := c.publish(t, id)
if n := scalar(t, c.a, "SELECT count(*) FROM objects"); n != 1 {
t.Fatalf("dedup objects=%d", n)
}
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
payload, _ := c.a.root.ReadFile(objectPath(oid))
if encrypted && bytes.Contains(payload, data) {
t.Fatal("plaintext at rest")
}
c.resolve(t, code)
w := c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), data) {
t.Fatal("roundtrip mismatch")
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 || scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[1]) != 3 {
t.Fatal("download counters not independent")
}
aExpiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
bExpiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[1])
if aExpiry-bExpiry != 3*86400 {
t.Fatalf("renewal delta=%d", aExpiry-bExpiry)
}
_, e := c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[0])
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("deleted shared payload prematurely")
}
_, e = c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[1])
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("unreferenced object leaked")
}
if _, e = c.a.root.Stat(objectPath(oid)); !os.IsNotExist(e) {
t.Fatal("payload not removed")
}
})
}
}
func TestLimitsAndOversizedBody(t *testing.T) {
c := fixture(t, false)
p := c.a.cfg.Policy
p.MaxFiles = 1
p.MaxFileBytes = 4
p.MaxBatchBytes = 4
b, _ := json.Marshal(p)
c.a.db.Exec("UPDATE settings SET json=?", string(b))
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{"a", 3}, {"b", 1}}}), 400)
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{"a", 5}}}), 400)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("1234")}, 2, false)
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], []byte("12345")), 400)
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("oversize object published")
}
expect(t, c.json(t, "POST", "/uploads/"+id+"/publish", struct{}{}), 409)
c.upload(t, id, files[0], []byte("1234"))
c.publish(t, id)
var billed int64
c.a.db.QueryRow("SELECT bytes FROM usage WHERE subject LIKE 'ip:%'").Scan(&billed)
if billed != 8 {
t.Fatalf("retry traffic billed=%d want=8", billed)
}
}
func TestConcurrentLastDownload(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("one")}, 1, true)
c.upload(t, id, files[0], []byte("one"))
c.resolve(t, c.publish(t, id))
cookie := *c.cookies["bb_pickup"]
var wg sync.WaitGroup
codes := make(chan int, 12)
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = "203.0.113.11:1234"
r.AddCookie(&cookie)
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
codes <- w.Code
}()
}
wg.Wait()
close(codes)
success := 0
for n := range codes {
if n == 200 {
success++
}
}
if success != 1 {
t.Fatalf("accepted %d downloads", success)
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 0 {
t.Fatal("counter below zero or unconsumed")
}
if scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0]) != scalar(t, c.a, "SELECT expires FROM parcels WHERE id=?", id) {
t.Fatal("exhausted file renewed")
}
}
func TestConcurrentCapacityReservation(t *testing.T) {
c := fixture(t, false)
c.a.cfg.FileBudget = reserveSize(1024, false) * 2
testRuntimeBudgets(t, c.a, c.a.cfg.FileBudget, c.a.cfg.DataBudget)
c.a.cfg.Policy.IPDailyUploads = 100
c.a.rateMu.Lock()
c.a.rates["upload:"+c.ip] = &rateEntry{limiter: rate.NewLimiter(rate.Inf, 100), touched: time.Now()}
c.a.rateMu.Unlock()
var wg sync.WaitGroup
statuses := make(chan int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
b, _ := json.Marshal(Manifest{Files: []ManifestFile{{"a", 1024}}})
r := httptest.NewRequest("POST", "http://localhost:8080/api/v1/uploads", bytes.NewReader(b))
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = c.ip + ":12"
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
statuses <- w.Code
}()
}
wg.Wait()
close(statuses)
success := 0
for n := range statuses {
if n == 201 {
success++
} else if n != 507 {
t.Fatalf("unexpected %d", n)
}
}
if success != 2 {
t.Fatalf("reserved %d sessions want2", success)
}
_, reserved, _, e := c.a.capacity()
if e != nil || reserved != c.a.cfg.FileBudget {
t.Fatal("reservation ledger inconsistent")
}
}
func TestPasswordOwnershipAndOrigin(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"../../evil.html"}, [][]byte{[]byte("<script>alert(1)</script>")}, 2, false)
other := &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: "203.0.113.20"}
expect(t, other.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], []byte("abc")), 404)
c.upload(t, id, files[0], []byte("<script>alert(1)</script>"))
code := c.publish(t, id)
hash, _ := hashPassword("pickup-password")
c.a.db.Exec("UPDATE parcels SET password_hash=? WHERE id=?", hash, id)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code}), 401)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code, "password": "wrong"}), 404)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code, "password": "pickup-password"}), 200)
w := c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil)
expect(t, w, 200)
if !strings.HasPrefix(w.Header().Get("Content-Disposition"), "attachment") || w.Header().Get("Content-Type") != "application/octet-stream" {
t.Fatal("active content served inline")
}
r := httptest.NewRequest("POST", "http://localhost:8080/api/v1/parcels/"+id+"/revoke", nil)
r.Header.Set("Origin", "https://evil.example")
r.AddCookie(c.cookies["bb_visitor"])
w = httptest.NewRecorder()
c.h.ServeHTTP(w, r)
expect(t, w, 403)
}
func TestRenewalModesAndLifetimeCeiling(t *testing.T) {
for _, mode := range []string{"add", "sliding"} {
t.Run(mode, func(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 4, true)
c.upload(t, id, files[0], []byte("content"))
c.resolve(t, c.publish(t, id))
expiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
hard := expiry + 86400
c.a.db.Exec("UPDATE parcels SET renewal_mode=? WHERE id=?", mode, id)
c.a.db.Exec("UPDATE files SET hard_expires=? WHERE id=?", hard, files[0])
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 200)
actual := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
expected := hard
if mode == "sliding" {
expected = expiry
}
if actual != expected {
t.Fatalf("expiry=%d want%d", actual, expected)
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 3 {
t.Fatal("renewal reset downloads")
}
})
}
}
type failWriter struct {
header http.Header
bytes int64
}
func (w *failWriter) Header() http.Header { return w.header }
func (w *failWriter) WriteHeader(int) {}
func (w *failWriter) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe }
func TestInterruptedDownloadDoesNotRenew(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, true)
c.upload(t, id, files[0], []byte("content"))
c.resolve(t, c.publish(t, id))
expiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = c.ip + ":12"
r.AddCookie(c.cookies["bb_pickup"])
c.h.ServeHTTP(&failWriter{header: make(http.Header)}, r)
if scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0]) != expiry {
t.Fatal("failed download renewed")
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("failed begun download not charged")
}
}
func TestBackupRestoreAndMissingKey(t *testing.T) {
c := fixture(t, true)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, true)
c.upload(t, id, files[0], []byte("content"))
code := c.publish(t, id)
config := filepath.Join(t.TempDir(), "config.toml")
os.WriteFile(config, []byte("listen='127.0.0.1:8080'\n"), 0600)
archive := filepath.Join(t.TempDir(), "backup.tar.gz")
if e := c.a.Backup(config, archive); e != nil {
t.Fatal(e)
}
cfg := c.a.cfg
cfg.DataDir = t.TempDir()
if e := Restore(cfg, archive); e != nil {
t.Fatal(e)
}
restored, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
if scalar(t, restored, "SELECT count(*) FROM objects") != 1 {
t.Fatal("restore lost payload")
}
if scalar(t, restored, "SELECT count(*) FROM parcels WHERE state='restored' AND code_hash IS NULL") != 1 {
t.Fatal("old share remains authorized")
}
if scalar(t, restored, "SELECT count(*) FROM parcels WHERE code_hash=?", restored.keyed(code)) != 0 {
t.Fatal("old code restored")
}
restored.Close()
os.Remove(filepath.Join(cfg.DataDir, "secrets", "age.key"))
if a, e := Open(cfg); e == nil {
a.Close()
t.Fatal("missing encrypted identity silently regenerated")
}
}
func TestRecoveryAndIPPersistence(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, false)
c.upload(t, id, files[0], []byte("content"))
cfg := c.a.cfg
c.a.Close()
c.a.db = nil
c.a.root = nil
c.a.lock = nil
a, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
defer a.Close()
if scalar(t, a, "SELECT count(*) FROM parcels WHERE state='revoked'") != 1 || scalar(t, a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("partial upload became accessible or leaked")
}
if scalar(t, a, "SELECT uploads FROM usage WHERE subject LIKE 'ip:%'") != 1 {
t.Fatal("IP daily counter reset")
}
r := httptest.NewRequest("GET", "http://localhost", nil)
r.RemoteAddr = "203.0.113.21:80"
r.Header.Set("X-Forwarded-For", "8.8.8.8")
if a.remoteIP(r) != "203.0.113.21" {
t.Fatal("untrusted forwarded IP accepted")
}
}
type gatedReader struct {
r *bytes.Reader
once sync.Once
started chan<- struct{}
release <-chan struct{}
}
func (r *gatedReader) Read(p []byte) (int, error) {
r.once.Do(func() { r.started <- struct{}{}; <-r.release })
return r.r.Read(p)
}
type sinkWriter struct {
header http.Header
status int
hash hash.Hash
count int64
once sync.Once
started chan<- struct{}
release <-chan struct{}
}
func (w *sinkWriter) Header() http.Header { return w.header }
func (w *sinkWriter) WriteHeader(n int) { w.status = n }
func (w *sinkWriter) Write(p []byte) (int, error) {
w.once.Do(func() { w.started <- struct{}{}; <-w.release })
if w.status == 0 {
w.status = 200
}
n, e := w.hash.Write(p)
w.count += int64(n)
return n, e
}
func TestConcurrentStreamingTransfers(t *testing.T) {
c := fixture(t, true)
mib := 8
if v, e := strconv.Atoi(os.Getenv("BLUEBIRD_STRESS_MIB")); e == nil && v >= 1 && v <= 100 {
mib = v
}
c.a.cfg.FileBudget = int64(mib*8+64) << 20
c.a.cfg.DataBudget = c.a.cfg.FileBudget + (64 << 20)
testRuntimeBudgets(t, c.a, c.a.cfg.FileBudget, c.a.cfg.DataBudget)
payload := bytes.Repeat([]byte("transfer"), mib*(1<<17))
clients := make([]*testClient, 8)
ids := make([]string, 8)
files := make([]string, 8)
for i := range clients {
clients[i] = &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: fmt.Sprintf("203.0.113.%d", 40+i)}
id, f := clients[i].create(t, []string{"big.bin"}, [][]byte{payload}, 20, true)
ids[i], files[i] = id, f[0]
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
started := make(chan struct{}, 8)
release := make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
var wg sync.WaitGroup
out := make(chan int, 8)
for i := range clients {
wg.Add(1)
go func(i int) {
defer wg.Done()
r := httptest.NewRequest("PUT", "http://localhost:8080/api/v1/uploads/"+ids[i]+"/files/"+files[i], &gatedReader{r: bytes.NewReader(payload), started: started, release: release})
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = clients[i].ip + ":1234"
for _, cookie := range clients[i].cookies {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
out <- w.Code
}(i)
}
for range 8 {
select {
case <-started:
case <-time.After(10 * time.Second):
t.Fatal("8 upload streams did not start together")
}
}
var duringUploads runtime.MemStats
runtime.ReadMemStats(&duringUploads)
uploadStart := time.Now()
releaseOnce.Do(func() { close(release) })
wg.Wait()
uploadDuration := time.Since(uploadStart)
close(out)
for status := range out {
if status != 200 {
t.Fatalf("upload status=%d", status)
}
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("simultaneous identical content not deduplicated")
}
for i := range clients {
clients[i].publish(t, ids[i])
}
var codeHash string
c.a.db.QueryRow("SELECT code_hash FROM parcels WHERE id=?", ids[0]).Scan(&codeHash)
_ = codeHash
code := pickupCode()
c.a.db.Exec("UPDATE parcels SET code_hash=? WHERE id=?", c.a.keyed(code), ids[0])
clients[0].resolve(t, code)
cookie := *clients[0].cookies["bb_pickup"]
downloadStarted := make(chan struct{}, 16)
downloadRelease := make(chan struct{})
var downOnce sync.Once
defer downOnce.Do(func() { close(downloadRelease) })
sinks := make([]*sinkWriter, 16)
for i := range sinks {
sinks[i] = &sinkWriter{header: make(http.Header), hash: sha256.New(), started: downloadStarted, release: downloadRelease}
wg.Add(1)
go func(i int) {
defer wg.Done()
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = fmt.Sprintf("203.0.113.%d:1234", 80+i)
r.AddCookie(&cookie)
c.h.ServeHTTP(sinks[i], r)
}(i)
}
for range 16 {
select {
case <-downloadStarted:
case <-time.After(10 * time.Second):
t.Fatal("16 download streams did not start together")
}
}
var duringDownloads runtime.MemStats
runtime.ReadMemStats(&duringDownloads)
downloadStart := time.Now()
downOnce.Do(func() { close(downloadRelease) })
wg.Wait()
downloadDuration := time.Since(downloadStart)
want := sha256.Sum256(payload)
for _, s := range sinks {
if s.status != 200 || s.count != int64(len(payload)) || !bytes.Equal(s.hash.Sum(nil), want[:]) {
t.Fatal("stream output checksum/count mismatch")
}
}
t.Logf("age enabled; 8 simultaneous ×%dMiB uploads: %s (%.1f MiB/s); 16 simultaneous ×%dMiB downloads: %s (%.1f MiB/s); heap delta at barrier: uploads=%.2fMiB downloads=%.2fMiB; in-process HTTP handlers, excludes network/TLS", mib, uploadDuration, float64(mib*8)/uploadDuration.Seconds(), mib, downloadDuration, float64(mib*16)/downloadDuration.Seconds(), float64(int64(duringUploads.Alloc)-int64(before.Alloc))/(1<<20), float64(int64(duringDownloads.Alloc)-int64(before.Alloc))/(1<<20))
}
func TestInjectedCommitFailureAndDeletionFailure(t *testing.T) {
c := fixture(t, true)
payload := []byte("test fault recovery")
id, files := c.create(t, []string{"a"}, [][]byte{payload}, 3, true)
if _, e := c.a.db.Exec("CREATE TRIGGER reject_object_attach BEFORE UPDATE OF object_id ON files WHEN NEW.object_id IS NOT NULL BEGIN SELECT RAISE(ABORT,'injected commit failure'); END"); e != nil {
t.Fatal(e)
}
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], payload), 500)
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("rolled-back object still registered")
}
entries, e := os.ReadDir(filepath.Join(c.a.cfg.DataDir, "objects"))
if e != nil {
t.Fatal(e)
}
for _, d := range entries {
sub, _ := os.ReadDir(filepath.Join(c.a.cfg.DataDir, "objects", d.Name()))
if len(sub) > 0 {
t.Fatal("failed commit left uncharged payload")
}
}
c.a.db.Exec("DROP TRIGGER reject_object_attach")
c.upload(t, id, files[0], payload)
c.publish(t, id)
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
path := objectPath(oid)
if e = c.a.root.Remove(path); e != nil {
t.Fatal(e)
}
if e = c.a.root.Mkdir(path, 0700); e != nil {
t.Fatal(e)
}
if e = c.a.root.WriteFile(path+"/blocker", []byte("x"), 0600); e != nil {
t.Fatal(e)
}
c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[0])
if e = c.a.Cleanup(); e == nil {
t.Fatal("expected physical deletion failure")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 || scalar(t, c.a, "SELECT sum(stored_bytes) FROM objects") <= 0 {
t.Fatal("failed deletion incorrectly released capacity")
}
c.a.root.Remove(path + "/blocker")
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("retry deletion failed")
}
}
func TestDeleteDuringUploadDoesNotRepublish(t *testing.T) {
c := fixture(t, false)
payload := []byte("delete while uploading")
id, files := c.create(t, []string{"a"}, [][]byte{payload}, 3, false)
started := make(chan struct{}, 1)
release := make(chan struct{})
defer func() {
select {
case <-release:
default:
close(release)
}
}()
r := httptest.NewRequest("PUT", "http://localhost:8080/api/v1/uploads/"+id+"/files/"+files[0], &gatedReader{r: bytes.NewReader(payload), started: started, release: release})
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = c.ip + ":12"
r.AddCookie(c.cookies["bb_visitor"])
done := make(chan *httptest.ResponseRecorder, 1)
go func() { w := httptest.NewRecorder(); c.h.ServeHTTP(w, r); done <- w }()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("stream did not start")
}
expect(t, c.json(t, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}), 200)
expect(t, c.json(t, "POST", "/admin/files/"+files[0]+"/delete", struct{}{}), 200)
_, reserved, _, e := c.a.capacity()
if e != nil || reserved != reserveSize(int64(len(payload)), false) {
t.Fatal("active deletion released upload reservation too early")
}
close(release)
expect(t, <-done, 500)
if scalar(t, c.a, "SELECT count(*) FROM files WHERE status='ready'") != 0 {
t.Fatal("deleted file became ready")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("canceled payload leaked")
}
if e := c.a.Cleanup(); e != nil {
t.Fatal(e)
}
}
func TestAnonymousCookieResetDoesNotResetIPQuota(t *testing.T) {
c := fixture(t, false)
p := c.a.cfg.Policy
p.IPDailyUploads = 1
b, _ := json.Marshal(p)
c.a.db.Exec("UPDATE settings SET json=?", string(b))
c.create(t, []string{"a"}, [][]byte{[]byte("x")}, 1, false)
fresh := &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: c.ip}
expect(t, fresh.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "a", Size: 1}}}), 429)
if scalar(t, c.a, "SELECT uploads FROM usage WHERE subject LIKE 'ip:%'") != 1 {
t.Fatal("daily quota mutated on rejection")
}
}
func TestFailedRestoreCannotStart(t *testing.T) {
cfg := DefaultConfig()
cfg.DataDir = t.TempDir()
cfg.MinFreeBytes = 0
archive := filepath.Join(t.TempDir(), "broken.tar.gz")
os.WriteFile(archive, []byte("not a backup"), 0600)
if e := Restore(cfg, archive); e == nil {
t.Fatal("invalid archive accepted")
}
if a, e := Open(cfg); e == nil {
a.Close()
t.Fatal("partially restored service can start")
}
}
func TestExclusiveLockAndZeroByteIntegrity(t *testing.T) {
c := fixture(t, true)
if another, e := Open(c.a.cfg); e == nil {
another.Close()
t.Fatal("second process lock succeeded")
}
id, files := c.create(t, []string{"empty"}, [][]byte{{}}, 2, true)
c.upload(t, id, files[0], nil)
c.resolve(t, c.publish(t, id))
expect(t, c.request(t, "HEAD", "/pickup/files/"+files[0]+"/download", nil), 200)
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("HEAD consumed count")
}
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
path := objectPath(oid)
raw, _ := c.a.root.ReadFile(path)
raw[len(raw)-1] ^= 1
c.a.root.WriteFile(path, raw, 0600)
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 500)
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("failed empty-content authentication consumed count")
}
}
func TestSlowUploadIdleDeadline(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"slow"}, [][]byte{[]byte("0123456789")}, 2, false)
c.a.cfg.TransferIdleSeconds = 1
c.a.cfg.MaxTransferSeconds = 10
srv := httptest.NewServer(c.h)
defer srv.Close()
c.a.cfg.PublicURL = srv.URL
conn, e := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
if e != nil {
t.Fatal(e)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
_, e = fmt.Fprintf(conn, "PUT /api/v1/uploads/%s/files/%s HTTP/1.1\r\nHost: %s\r\nOrigin: %s\r\nCookie: bb_visitor=%s\r\nContent-Length: 10\r\nConnection: close\r\n\r\nx", id, files[0], strings.TrimPrefix(srv.URL, "http://"), srv.URL, c.cookies["bb_visitor"].Value)
if e != nil {
t.Fatal(e)
}
start := time.Now()
response, e := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "PUT"})
if e != nil {
t.Fatal(e)
}
defer response.Body.Close()
io.Copy(io.Discard, response.Body)
if response.StatusCode != 400 || time.Since(start) > 4*time.Second {
t.Fatal("idle upload was not rejected promptly")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("slow partial content became available")
}
}
func TestTrustedProxyChainAndIPv6(t *testing.T) {
c := fixture(t, false)
_, network, _ := net.ParseCIDR("10.0.0.0/8")
c.a.proxies = []*net.IPNet{network}
r := httptest.NewRequest("GET", "http://localhost", nil)
r.RemoteAddr = "10.0.0.1:80"
r.Header.Set("X-Forwarded-For", "8.8.8.8, 203.0.113.9, 10.0.0.2")
if c.a.remoteIP(r) != "203.0.113.9" {
t.Fatal("trusted proxy chain accepted leftmost forged IP")
}
r.RemoteAddr = "[2001:db8:1:2::1]:80"
r.Header.Del("X-Forwarded-For")
first := c.a.remoteIP(r)
r.RemoteAddr = "[2001:db8:1:2::ffff]:80"
if c.a.remoteIP(r) != first {
t.Fatal("IPv6 rotation evaded prefix grouping")
}
}
func testRuntimeBudgets(t *testing.T, a *App, file, data int64) {
t.Helper()
s, e := a.runtimeSettings()
if e != nil {
t.Fatal(e)
}
s.FileBudget = file
s.DataBudget = data
b, _ := json.Marshal(s)
if _, e = a.db.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b)); e != nil {
t.Fatal(e)
}
}
+211
View File
@@ -0,0 +1,211 @@
package app
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Backup is an offline operation: Open has already acquired the exclusive service lock.
func (a *App) Backup(configPath, target string) error {
abs, e := filepath.Abs(target)
if e != nil {
return e
}
data, e := filepath.Abs(a.cfg.DataDir)
if e != nil {
return e
}
if abs == data || strings.HasPrefix(abs, data+string(os.PathSeparator)) {
return errors.New("备份目标必须在数据目录之外")
}
if e = a.db.Close(); e != nil {
return e
}
a.db = nil
out, e := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
return e
}
success := false
defer func() {
out.Close()
if !success {
os.Remove(abs)
}
}()
gz := gzip.NewWriter(out)
tw := tar.NewWriter(gz)
write := func(name string, f *os.File, st os.FileInfo) error {
h := &tar.Header{Name: name, Mode: 0600, Size: st.Size(), ModTime: st.ModTime(), Typeflag: tar.TypeReg}
if e := tw.WriteHeader(h); e != nil {
return e
}
_, e := io.Copy(tw, f)
return e
}
cf, e := os.Open(configPath)
if e != nil {
return e
}
st, e := cf.Stat()
if e == nil {
e = write("config.toml", cf, st)
}
cf.Close()
if e != nil {
return e
}
e = fs.WalkDir(a.root.FS(), ".", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("拒绝备份符号链接: %s", path)
}
if d.IsDir() || path == "service.lock" {
return nil
}
if strings.HasPrefix(path, "tmp/") {
return nil
}
st, e := d.Info()
if e != nil {
return e
}
if !st.Mode().IsRegular() {
return errors.New("数据目录包含特殊文件")
}
f, e := a.root.Open(path)
if e != nil {
return e
}
defer f.Close()
return write("var/"+path, f, st)
})
if e != nil {
return e
}
if e = tw.Close(); e != nil {
return e
}
if e = gz.Close(); e != nil {
return e
}
if e = out.Sync(); e != nil {
return e
}
success = true
return nil
}
func Restore(c Config, source string) error {
if e := c.Validate(); e != nil {
return e
}
if e := os.MkdirAll(c.DataDir, 0700); e != nil {
return e
}
entries, e := os.ReadDir(c.DataDir)
if e != nil {
return e
}
if len(entries) != 0 {
return errors.New("恢复数据目录必须为空")
}
root, e := os.OpenRoot(c.DataDir)
if e != nil {
return e
}
defer root.Close()
if e = root.WriteFile("restore.incomplete", []byte("restoring"), 0600); e != nil {
return e
}
input, e := os.Open(source)
if e != nil {
return e
}
defer input.Close()
gz, e := gzip.NewReader(input)
if e != nil {
return e
}
defer gz.Close()
tr := tar.NewReader(gz)
var total int64
var count int
for {
h, e := tr.Next()
if errors.Is(e, io.EOF) {
break
}
if e != nil {
return e
}
if h.Typeflag != tar.TypeReg || !safeRelative(h.Name) {
return errors.New("备份包含非法路径或文件类型")
}
count++
if count > c.MaxRecords+32 {
return errors.New("备份文件数量超出恢复限制")
}
name := ""
if h.Name == "config.toml" {
name = "restored-config.toml"
} else if strings.HasPrefix(h.Name, "var/") {
name = strings.TrimPrefix(h.Name, "var/")
} else {
return errors.New("未知备份路径")
}
if !safeRelative(name) || h.Size < 0 || h.Size > c.DataBudget || total > c.DataBudget-h.Size {
return errors.New("备份超过恢复容量")
}
total += h.Size
if e = root.MkdirAll(filepath.Dir(name), 0700); e != nil {
return e
}
f, e := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
return e
}
_, e = io.CopyN(f, tr, h.Size)
if e == nil {
e = f.Sync()
}
ce := f.Close()
if e == nil {
e = ce
}
if e != nil {
return e
}
}
if _, e = io.Copy(io.Discard, gz); e != nil {
return e
}
// Invalidate before Open runs lifecycle cleanup, leaving payloads retained for admin review.
dbPath := filepath.Join(c.DataDir, "db", "bluebird.db")
if _, e = os.Stat(dbPath); e != nil {
return errors.New("备份缺少数据库")
}
a, e := OpenForRestore(c)
if e != nil {
return e
}
defer a.Close()
if e = a.Check(); e != nil {
return e
}
if e = a.InvalidateRestored(); e != nil {
return e
}
if e = root.Remove("restore.incomplete"); e != nil {
return e
}
return syncDir(root, ".")
}
+157
View File
@@ -0,0 +1,157 @@
package app
import (
"bytes"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"github.com/pelletier/go-toml/v2"
)
type Policy struct {
AnonymousUpload bool `json:"anonymous_upload" toml:"anonymous_upload"`
AnonymousDownload bool `json:"anonymous_download" toml:"anonymous_download"`
MaxFileBytes int64 `json:"max_file_bytes" toml:"max_file_bytes"`
MaxBatchBytes int64 `json:"max_batch_bytes" toml:"max_batch_bytes"`
MaxFiles int `json:"max_files" toml:"max_files"`
DefaultDays int `json:"default_days" toml:"default_days"`
MaxInitialDays int `json:"max_initial_days" toml:"max_initial_days"`
MaxLifetimeDays int `json:"max_lifetime_days" toml:"max_lifetime_days"`
DefaultDownloads int `json:"default_downloads" toml:"default_downloads"`
MaxDownloads int `json:"max_downloads" toml:"max_downloads"`
RenewalDays int `json:"renewal_days" toml:"renewal_days"`
RenewalMode string `json:"renewal_mode" toml:"renewal_mode"`
IPDailyBytes int64 `json:"ip_daily_bytes" toml:"ip_daily_bytes"`
IPDailyUploads int `json:"ip_daily_uploads" toml:"ip_daily_uploads"`
SessionDailyUploads int `json:"session_daily_uploads" toml:"session_daily_uploads"`
RequirePassword bool `json:"require_password" toml:"require_password"`
}
type Config struct {
Listen string `toml:"listen"`
PublicURL string `toml:"public_url"`
AccessURLs []string `toml:"access_urls"`
DataDir string `toml:"data_dir"`
SecureCookies bool `toml:"secure_cookies"`
AllowLANHTTP bool `toml:"allow_lan_http"`
Encryption bool `toml:"encryption"`
Deduplication bool `toml:"deduplication"`
TrustedProxies []string `toml:"trusted_proxies"`
DataBudget int64 `toml:"data_budget"`
FileBudget int64 `toml:"file_budget"`
MinFreeBytes int64 `toml:"min_free_bytes"`
AuditRetentionDays int `toml:"audit_retention_days"`
AuditMaxRecords int `toml:"audit_max_records"`
AuditMaxBytes int64 `toml:"audit_max_bytes"`
MaxUploads int `toml:"max_uploads"`
MaxDownloads int `toml:"max_downloads"`
MaxIPUploads int `toml:"max_ip_uploads"`
MaxIPDownloads int `toml:"max_ip_downloads"`
MaxRecords int `toml:"max_records"`
TransferIdleSeconds int `toml:"transfer_idle_seconds"`
MaxTransferSeconds int `toml:"max_transfer_seconds"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
Policy Policy `toml:"policy"`
}
func DefaultConfig() Config {
return Config{
Listen: "127.0.0.1:8080", PublicURL: "http://localhost:8080", DataDir: "var", Deduplication: true,
DataBudget: 10 << 30, FileBudget: 9 << 30, MinFreeBytes: 1 << 30, MaxUploads: 8, MaxDownloads: 16, MaxIPUploads: 2, MaxIPDownloads: 4, MaxRecords: 200000, MaxTransferSeconds: 3600, TransferIdleSeconds: 30,
AuditRetentionDays: 30, AuditMaxRecords: 100000, AuditMaxBytes: 32 << 20,
Policy: Policy{AnonymousUpload: true, AnonymousDownload: true, MaxFileBytes: 100 << 20, MaxBatchBytes: 500 << 20, MaxFiles: 10, DefaultDays: 1, MaxInitialDays: 7, MaxLifetimeDays: 30, DefaultDownloads: 5, MaxDownloads: 20, RenewalDays: 3, RenewalMode: "add", IPDailyBytes: 1 << 30, IPDailyUploads: 20, SessionDailyUploads: 20},
}
}
func (p Policy) Validate() error {
if p.MaxBatchBytes > 1<<40 || p.IPDailyBytes > 1<<50 || p.MaxFileBytes <= 0 || p.MaxBatchBytes <= 0 || p.MaxFiles < 1 || p.MaxFiles > 1000 || p.MaxFileBytes > p.MaxBatchBytes || p.DefaultDays < 1 || p.DefaultDays > p.MaxInitialDays || p.MaxInitialDays > p.MaxLifetimeDays || p.MaxLifetimeDays > 3650 || p.DefaultDownloads < 1 || p.DefaultDownloads > p.MaxDownloads || p.MaxDownloads > 100000 || p.RenewalDays < 0 || p.RenewalDays > p.MaxLifetimeDays || p.IPDailyBytes < 1 || p.IPDailyUploads < 1 || p.SessionDailyUploads < 1 {
return errors.New("策略参数范围错误")
}
if p.RenewalMode != "add" && p.RenewalMode != "sliding" {
return errors.New("renewal_mode 必须为 add 或 sliding")
}
return nil
}
func LoadConfig(path string) (Config, error) {
c := DefaultConfig()
b, e := os.ReadFile(path)
if e != nil {
return c, e
}
if e = toml.NewDecoder(bytes.NewReader(b)).DisallowUnknownFields().Decode(&c); e != nil {
return c, e
}
base, e := filepath.Abs(filepath.Dir(path))
if e != nil {
return c, e
}
if !filepath.IsAbs(c.DataDir) {
c.DataDir = filepath.Join(base, c.DataDir)
}
for _, ptr := range []*string{&c.TLSCert, &c.TLSKey} {
if *ptr != "" && !filepath.IsAbs(*ptr) {
*ptr = filepath.Join(base, *ptr)
}
}
return c, c.Validate()
}
func (c Config) Validate() error {
if e := c.InitialRuntimeSettings().Validate(); e != nil {
return e
}
if e := c.Policy.Validate(); e != nil {
return e
}
primary, e := normalizeAccessURL(c.PublicURL)
if e != nil {
return e
}
u, _ := url.Parse(primary)
if len(c.AccessURLs) > 16 {
return errors.New("额外访问地址最多16个")
}
for _, raw := range append([]string{c.PublicURL}, c.AccessURLs...) {
base, err := normalizeAccessURL(raw)
if err != nil {
return err
}
entry, _ := url.Parse(base)
ip := net.ParseIP(entry.Hostname())
if entry.Scheme == "http" && ip != nil && ip.IsPrivate() && !ip.IsLoopback() && !c.AllowLANHTTP {
return errors.New("内网HTTP入口须显式开启 allow_lan_http")
}
}
if u.Scheme == "https" && !c.SecureCookies {
return errors.New("HTTPS主入口须设置 secure_cookies = true")
}
if u.Scheme == "http" && c.SecureCookies {
return errors.New("HTTP主入口须设置 secure_cookies = false;额外HTTPS入口自动使用安全cookie")
}
listenHost, port, err := net.SplitHostPort(c.Listen)
if err != nil || port == "" {
return errors.New("listen须包含地址和端口")
}
// Preserve explicit-address protection for a single local/LAN HTTP site.
ip := net.ParseIP(u.Hostname())
if u.Scheme == "http" && (u.Hostname() == "localhost" || ip != nil && (ip.IsPrivate() || ip.IsLoopback())) {
bound := net.ParseIP(listenHost)
if listenHost != "localhost" && (bound == nil || !bound.IsPrivate() && !bound.IsLoopback()) {
return errors.New("内网HTTP须监听具体私有或回环地址")
}
}
if (c.TLSCert == "") != (c.TLSKey == "") {
return errors.New("TLS 证书和私钥需一起配置")
}
if c.FileBudget <= 0 || c.DataBudget-c.FileBudget < 64<<20 || c.MinFreeBytes < 0 || c.MaxUploads < 1 || c.MaxDownloads < 1 || c.MaxIPUploads < 1 || c.MaxIPDownloads < 1 || c.MaxRecords < 1 || c.MaxTransferSeconds < 1 || c.TransferIdleSeconds < 1 {
return fmt.Errorf("容量、并发或传输配置非法")
}
for _, p := range c.TrustedProxies {
if _, _, e := net.ParseCIDR(p); e != nil {
return fmt.Errorf("非法代理网段: %s", p)
}
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestConfigurationSafety(t *testing.T) {
c := DefaultConfig()
c.Listen = "0.0.0.0:8080"
if c.Validate() == nil {
t.Fatal("plain HTTP public listener accepted")
}
dir := t.TempDir()
p := filepath.Join(dir, "config.toml")
os.WriteFile(p, []byte("unknown_setting = true\n"), 0600)
if _, e := LoadConfig(p); e == nil {
t.Fatal("unknown configuration silently accepted")
}
os.WriteFile(p, []byte("data_dir = 'var'\n"), 0600)
loaded, e := LoadConfig(p)
if e != nil {
t.Fatal(e)
}
if loaded.DataDir != filepath.Join(dir, "var") {
t.Fatal("relative directory not resolved from configuration")
}
}
func TestLANHTTPRequiresExplicitPrivateAddress(t *testing.T) {
for _, test := range []struct {
name, listen, publicURL string
allow, valid bool
}{
{"disabled", "192.168.1.204:8080", "http://192.168.1.204:8080", false, false},
{"explicit LAN", "192.168.1.204:8080", "http://192.168.1.204:8080", true, true},
{"wildcard IPv4", "0.0.0.0:8080", "http://192.168.1.204:8080", true, false},
{"public listener", "8.8.8.8:8080", "http://192.168.1.204:8080", true, false},
{"public HTTP URL", "192.168.1.204:8080", "http://8.8.8.8:8080", true, true},
{"private IPv6", "[fd00::204]:8080", "http://[fd00::204]:8080", true, true},
{"wildcard IPv6", "[::]:8080", "http://[fd00::204]:8080", true, false},
{"loopback", "127.0.0.1:8080", "http://127.0.0.1:8080", false, true},
} {
t.Run(test.name, func(t *testing.T) {
c := DefaultConfig()
c.Listen, c.PublicURL, c.AllowLANHTTP = test.listen, test.publicURL, test.allow
if err := c.Validate(); (err == nil) != test.valid {
t.Fatalf("valid=%v, expected %v; error=%v", err == nil, test.valid, err)
}
})
}
c := DefaultConfig()
c.Listen, c.PublicURL, c.AllowLANHTTP, c.SecureCookies = "192.168.1.204:8080", "http://192.168.1.204:8080", true, true
if c.Validate() == nil {
t.Fatal("Secure cookies accepted for LAN HTTP")
}
}
+381
View File
@@ -0,0 +1,381 @@
package app
import (
"database/sql"
"errors"
"io"
"mime"
"net/http"
"strconv"
"time"
"filippo.io/age"
)
func (a *App) resolve(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "pickup", 10) {
fail(w, 429, "取件尝试过于频繁")
return
}
var in struct {
Code string `json:"code"`
Password string `json:"password"`
}
if !decode(w, r, &in) {
return
}
code := normalizeCode(in.Code)
if len(code) != 10 {
fail(w, 404, "取件信息无效或已失效")
return
}
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
if !p.AnonymousDownload {
fail(w, 403, "管理员已关闭取件")
return
}
var id, hash string
var version int
now := a.now().Unix()
e = a.db.QueryRow("SELECT id,password_hash,version FROM parcels WHERE code_hash=? AND state='ready' AND EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0 AND expires>?)", a.keyed(code), now).Scan(&id, &hash, &version)
if e != nil {
if !errors.Is(e, sql.ErrNoRows) {
a.serverError(w, e)
return
}
fail(w, 404, "取件信息无效或已失效")
return
}
if hash != "" {
if in.Password == "" {
respond(w, 401, map[string]any{"error": "请输入取件密码", "password_required": true})
return
}
select {
case a.passwordSlots <- struct{}{}:
default:
fail(w, 429, "密码验证繁忙")
return
}
valid := verifyPassword(in.Password, hash)
<-a.passwordSlots
if !valid {
fail(w, 404, "取件信息无效或已失效")
return
}
}
a.mu.Lock()
defer a.mu.Unlock()
var current int
e = a.db.QueryRow("SELECT version FROM parcels WHERE id=? AND state='ready' AND code_hash=?", id, a.keyed(code)).Scan(&current)
if e != nil || current != version {
fail(w, 404, "取件信息无效或已失效")
return
}
var count int
if e = a.db.QueryRow("SELECT count(*) FROM grants").Scan(&count); e != nil {
a.serverError(w, e)
return
}
if count >= a.cfg.MaxRecords {
fail(w, 429, "取件会话繁忙")
return
}
token := randomToken()
if _, e = a.db.Exec("INSERT INTO grants VALUES(?,?,?,?)", tokenHash(token), id, version, now+600); e != nil {
a.serverError(w, e)
return
}
a.cookie(w, r, "bb_pickup", token, "/api/v1/pickup", 600)
a.pickup(w, r.Clone(r.Context()), id)
}
// Explicit parcel IDs are only used immediately after a validated code exchange.
func (a *App) grant(r *http.Request) (string, error) {
var id string
e := a.db.QueryRow("SELECT p.id FROM grants g JOIN parcels p ON p.id=g.parcel WHERE g.token_hash=? AND g.expires>? AND p.state='ready' AND p.version=g.version", tokenHash(cookieValue(r, "bb_pickup")), a.now().Unix()).Scan(&id)
return id, e
}
func (a *App) fileList(id string) ([]map[string]any, error) {
rows, e := a.db.Query("SELECT id,name,size,status,remaining,expires FROM files WHERE parcel=? ORDER BY rowid", id)
if e != nil {
return nil, e
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var fid, name, status string
var size, expires int64
var remaining int
if e = rows.Scan(&fid, &name, &size, &status, &remaining, &expires); e != nil {
return nil, e
}
out = append(out, map[string]any{"id": fid, "name": name, "size": size, "status": status, "remaining": remaining, "expires": expires, "available": status == "ready" && remaining > 0 && expires > a.now().Unix()})
}
return out, rows.Err()
}
func (a *App) pickup(w http.ResponseWriter, r *http.Request, explicit ...string) {
id := ""
if len(explicit) > 0 {
id = explicit[0]
} else {
var e error
id, e = a.grant(r)
if e != nil {
fail(w, 401, "取件授权已失效,请重新输入取件码")
return
}
}
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
if !p.AnonymousDownload {
fail(w, 403, "管理员已关闭取件")
return
}
files, e := a.fileList(id)
if e != nil {
a.serverError(w, e)
return
}
var renewal int
var mode string
if e = a.db.QueryRow("SELECT renewal_days,renewal_mode FROM parcels WHERE id=?", id).Scan(&renewal, &mode); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, map[string]any{"id": id, "files": files, "renewal_days": renewal, "renewal_mode": mode})
}
func (a *App) pickupHandler(w http.ResponseWriter, r *http.Request) { a.pickup(w, r) }
type startWriter struct {
dst http.ResponseWriter
start func() error
started bool
bytes int64
controller *http.ResponseController
total time.Time
idle time.Duration
}
func (w *startWriter) Write(b []byte) (int, error) {
if !w.started {
if e := w.start(); e != nil {
return 0, e
}
w.started = true
}
if w.controller != nil {
end := time.Now().Add(w.idle)
if end.After(w.total) {
end = w.total
}
_ = w.controller.SetWriteDeadline(end)
}
n, e := w.dst.Write(b)
w.bytes += int64(n)
return n, e
}
func (a *App) download(w http.ResponseWriter, r *http.Request) {
ip := a.remoteIP(r)
if !a.acquire("download", ip) {
fail(w, 429, "下载并发已满")
return
}
defer a.release("download", ip)
a.deadlines(w, r)
a.mu.Lock()
parcel, e := a.grant(r)
if e != nil {
a.mu.Unlock()
fail(w, 401, "取件授权失效")
return
}
policy, e := a.policy()
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
if !policy.AnonymousDownload {
a.mu.Unlock()
fail(w, 403, "取件已关闭")
return
}
fid := r.PathValue("file")
var oid, name, status string
var size, expiry int64
var remaining int
var encrypted bool
e = a.db.QueryRow("SELECT f.object_id,f.name,f.size,f.status,f.remaining,f.expires,o.encrypted FROM files f JOIN objects o ON o.id=f.object_id WHERE f.id=? AND f.parcel=?", fid, parcel).Scan(&oid, &name, &size, &status, &remaining, &expiry, &encrypted)
if e != nil || status != "ready" || remaining < 1 || expiry <= a.now().Unix() {
a.mu.Unlock()
fail(w, 404, "文件已失效")
return
}
file, e := a.root.Open(objectPath(oid))
if e != nil {
a.mu.Unlock()
fail(w, 404, "文件不可用")
return
}
a.activeObjects[oid]++
defer func() {
a.mu.Lock()
_ = file.Close()
a.activeObjects[oid]--
if a.activeObjects[oid] == 0 {
delete(a.activeObjects, oid)
}
a.mu.Unlock()
}()
var reader io.Reader = file
if encrypted {
if a.identity == nil {
a.mu.Unlock()
a.serverError(w, errors.New("缺少私钥"))
return
}
reader, e = age.Decrypt(file, a.identity)
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
}
if r.Method == "HEAD" {
a.mu.Unlock()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(200)
return
}
attempt := randomID()
tx, e := a.db.Begin()
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
result, e := tx.Exec("UPDATE files SET remaining=remaining-1 WHERE id=? AND remaining>0 AND status='ready' AND expires>?", fid, a.now().Unix())
if e == nil {
n, _ := result.RowsAffected()
if n != 1 {
e = errors.New("次数或期限不足")
}
}
if e == nil {
_, e = tx.Exec("INSERT INTO downloads(id,file_id,created) VALUES(?,?,?)", attempt, fid, a.now().Unix())
}
if e == nil {
e = tx.Commit()
} else {
tx.Rollback()
}
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
a.mu.Unlock()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("Accept-Ranges", "none")
writer := &startWriter{dst: w, controller: http.NewResponseController(w), total: time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second), idle: time.Duration(a.cfg.TransferIdleSeconds) * time.Second, start: func() error { _, e := a.db.Exec("UPDATE downloads SET started=1 WHERE id=?", attempt); return e }}
// A zero-byte download is still an accepted download and must consume a count.
if size == 0 {
var b [1]byte
n, re := reader.Read(b[:])
if n != 0 || re != io.EOF {
e = errors.New("空文件内容异常")
}
if e == nil {
e = writer.start()
}
if e == nil {
writer.started = true
w.WriteHeader(200)
}
} else {
_, e = io.CopyBuffer(writer, io.LimitReader(reader, size+1), make([]byte, 128<<10))
}
a.mu.Lock()
defer a.mu.Unlock()
if !writer.started {
a.refund(attempt, fid)
w.Header().Del("Content-Length")
w.Header().Del("Content-Disposition")
a.serverError(w, errors.New("下载开始前失败"))
return
}
if e == nil && writer.bytes == size {
if e = a.completeDownload(attempt, fid, parcel); e != nil {
a.log.Error("download completion failed", "error", e)
}
} else {
a.log.Warn("download interrupted", "file", fid, "bytes", writer.bytes)
}
}
func (a *App) refund(attempt, fid string) {
tx, e := a.db.Begin()
if e != nil {
return
}
defer tx.Rollback()
res, e := tx.Exec("UPDATE downloads SET refunded=1 WHERE id=? AND started=0 AND refunded=0", attempt)
if e != nil {
return
}
n, _ := res.RowsAffected()
if n == 1 {
_, e = tx.Exec("UPDATE files SET remaining=remaining+1 WHERE id=?", fid)
}
if e == nil {
_ = tx.Commit()
}
}
func (a *App) completeDownload(attempt, fid, parcel string) error {
tx, e := a.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
res, e := tx.Exec("UPDATE downloads SET completed=1 WHERE id=? AND completed=0", attempt)
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 {
return tx.Commit()
}
var days, remaining int
var mode, state, status string
var expiry, hard int64
e = tx.QueryRow("SELECT p.renewal_days,p.renewal_mode,p.state,f.status,f.expires,f.hard_expires,f.remaining FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=?", fid).Scan(&days, &mode, &state, &status, &expiry, &hard, &remaining)
if e != nil {
return e
}
if state == "ready" && status == "ready" && remaining > 0 && days > 0 {
newExpiry := max(expiry, a.now().Unix()+int64(days)*86400)
if mode == "add" {
newExpiry = max(expiry, a.now().Unix()) + int64(days)*86400
}
newExpiry = min(newExpiry, hard)
if newExpiry > expiry {
if _, e = tx.Exec("UPDATE files SET expires=? WHERE id=?", newExpiry, fid); e != nil {
return e
}
if _, e = tx.Exec("UPDATE parcels SET expires=(SELECT max(expires) FROM files WHERE parcel=?) WHERE id=?", parcel, parcel); e != nil {
return e
}
}
}
return tx.Commit()
}
+71
View File
@@ -0,0 +1,71 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"strings"
"filippo.io/age"
)
func (a *App) verifyObjects() error {
rows, e := a.db.Query("SELECT id,digest,size,encrypted,key_id FROM objects")
if e != nil {
return e
}
type object struct {
id, digest, key string
size int64
encrypted bool
}
objects := []object{}
for rows.Next() {
var o object
if e = rows.Scan(&o.id, &o.digest, &o.size, &o.encrypted, &o.key); e != nil {
rows.Close()
return e
}
objects = append(objects, o)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, o := range objects {
if len(o.id) != 32 {
return errors.New("非法对象ID")
}
if _, e = hex.DecodeString(o.id); e != nil {
return e
}
f, e := a.root.Open(objectPath(o.id))
if e != nil {
return e
}
var reader io.Reader = f
if o.encrypted {
if a.identity == nil {
f.Close()
return errors.New("缺少加密私钥")
}
reader, e = age.Decrypt(f, a.identity)
if e != nil {
f.Close()
return e
}
}
h := sha256.New()
n, e := io.CopyBuffer(h, reader, make([]byte, 128<<10))
f.Close()
if e != nil {
return e
}
if n != o.size || a.keyed(hex.EncodeToString(h.Sum(nil))+"/"+o.key) != strings.Split(o.digest, "/")[0] {
return errors.New("对象内容完整性检查失败: " + o.id)
}
}
return nil
}
+337
View File
@@ -0,0 +1,337 @@
package app
import (
"encoding/hex"
"errors"
"io/fs"
"os"
"strings"
)
func (a *App) recover() error {
// No other process owns this data directory. Partial sessions are never published.
// An interrupted attempt may have transferred bytes not yet recorded; charge its full allocation conservatively.
if _, e := a.db.Exec("UPDATE files SET received=max(received,charged) WHERE status='pending' AND attempts>0 AND parcel IN(SELECT id FROM parcels WHERE state='uploading')"); e != nil {
return e
}
if _, e := a.db.Exec("UPDATE parcels SET state='revoked',version=version+1 WHERE state='uploading'"); e != nil {
return e
}
entries, e := fs.ReadDir(a.root.FS(), "tmp")
if e != nil {
return e
}
for _, entry := range entries {
if entry.Type()&os.ModeSymlink != 0 {
return errors.New("临时目录存在符号链接")
}
if e = a.root.RemoveAll("tmp/" + entry.Name()); e != nil {
return e
}
}
rows, e := a.db.Query("SELECT id FROM objects")
if e != nil {
return e
}
known := map[string]bool{}
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
known[id] = true
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for id := range known {
if len(id) != 32 {
return errors.New("非法对象标识")
}
if _, e := hex.DecodeString(id); e != nil {
return errors.New("非法对象标识")
}
st, e := a.root.Lstat(objectPath(id))
if e != nil || !st.Mode().IsRegular() {
if e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if _, e = a.db.Exec("UPDATE files SET status='missing' WHERE object_id=?", id); e != nil {
return e
}
}
}
e = fs.WalkDir(a.root.FS(), "objects", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return errors.New("对象目录存在符号链接")
}
if d.IsDir() {
return nil
}
if !known[d.Name()] {
return a.root.Rename(path, "quarantine/"+randomID())
}
return nil
})
if e != nil {
return e
}
return a.Cleanup()
}
func (a *App) settleUsage(parcel string) error {
var ip, visitor, day string
if e := a.db.QueryRow("SELECT ip,visitor,usage_day FROM parcels WHERE id=?", parcel).Scan(&ip, &visitor, &day); e != nil {
return e
}
var refund int64
if e := a.db.QueryRow("SELECT COALESCE(sum(max(charged-received,0)),0) FROM files WHERE parcel=?", parcel).Scan(&refund); e != nil {
return e
}
tx, e := a.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
for _, s := range []string{"ip:" + a.keyed(ip), "visitor:" + visitor} {
if _, e = tx.Exec("UPDATE usage SET bytes=max(0,bytes-?) WHERE subject=? AND day=?", refund, s, day); e != nil {
return e
}
}
if _, e = tx.Exec("UPDATE files SET charged=received WHERE parcel=?", parcel); e != nil {
return e
}
return tx.Commit()
}
func (a *App) Cleanup() error {
a.mu.Lock()
defer a.mu.Unlock()
now := a.now().Unix()
rows, e := a.db.Query("SELECT id,state,upload_expires FROM parcels WHERE state='uploading' OR state IN('revoked','expired','exhausted')")
if e != nil {
return e
}
type parcel struct {
id, state string
expiry int64
}
parcels := []parcel{}
for rows.Next() {
var p parcel
if e = rows.Scan(&p.id, &p.state, &p.expiry); e != nil {
rows.Close()
return e
}
parcels = append(parcels, p)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, p := range parcels {
if p.state == "uploading" && p.expiry > now {
continue
}
rows, e := a.db.Query("SELECT id FROM files WHERE parcel=?", p.id)
if e != nil {
return e
}
active := false
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
if a.activeFiles[id] {
active = true
}
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
if active {
continue
}
if p.state == "uploading" {
if _, e = a.db.Exec("UPDATE parcels SET state='expired',version=version+1 WHERE id=?", p.id); e != nil {
return e
}
}
if e = a.settleUsage(p.id); e != nil {
return e
}
if _, e = a.db.Exec("UPDATE files SET status='deleting' WHERE parcel=?", p.id); e != nil {
return e
}
}
rows, e = a.db.Query("SELECT f.id,COALESCE(f.object_id,''),f.status,f.expires,f.remaining,p.state FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.status!='deleted'")
if e != nil {
return e
}
type file struct {
id, object, status, state string
expiry int64
remaining int
}
files := []file{}
for rows.Next() {
var f file
if e = rows.Scan(&f.id, &f.object, &f.status, &f.expiry, &f.remaining, &f.state); e != nil {
rows.Close()
return e
}
files = append(files, f)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, f := range files {
if a.activeFiles[f.id] || a.activeObjects[f.object] > 0 {
continue
}
remove := f.status == "deleting" || f.status == "missing" || (f.state == "ready" && (f.remaining == 0 || f.expiry <= now))
if !remove {
continue
}
if e = a.root.Remove("tmp/" + f.id); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if _, e = a.db.Exec("UPDATE files SET status='deleted',reserved=0,object_id=NULL WHERE id=?", f.id); e != nil {
return e
}
}
// Objects remain charged until unlink succeeds. Sharing records never unlink each other's payloads.
rows, e = a.db.Query("SELECT id FROM objects WHERE NOT EXISTS(SELECT 1 FROM files WHERE object_id=objects.id)")
if e != nil {
return e
}
ids := []string{}
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
ids = append(ids, id)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
for _, id := range ids {
if a.activeObjects[id] > 0 {
continue
}
if e = a.root.Remove(objectPath(id)); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if _, e = a.db.Exec("DELETE FROM objects WHERE id=?", id); e != nil {
return e
}
}
if _, e = a.db.Exec("UPDATE parcels SET state='exhausted',version=version+1 WHERE state='ready' AND NOT EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0)"); e != nil {
return e
}
for _, q := range []struct {
sql string
args []any
}{
{"DELETE FROM visitors WHERE expires<=?", []any{now}}, {"DELETE FROM grants WHERE expires<=?", []any{now}}, {"DELETE FROM admin_sessions WHERE expires<=?", []any{now}}, {"DELETE FROM usage WHERE day<?", []any{a.now().UTC().AddDate(0, 0, -8).Format("2006-01-02")}}, {"DELETE FROM downloads WHERE created<?", []any{now - 30*86400}}, {"DELETE FROM parcels WHERE state IN('revoked','expired','exhausted') AND created<? AND NOT EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status!='deleted')", []any{now - 30*86400}},
} {
if _, e = a.db.Exec(q.sql, q.args...); e != nil {
return e
}
}
if e = a.trimAudit(); e != nil {
return e
}
entries, e := fs.ReadDir(a.root.FS(), "quarantine")
if e != nil {
return e
}
for _, d := range entries {
st, e := d.Info()
if e != nil {
return e
}
if now-st.ModTime().Unix() > 86400 {
if e = a.root.Remove("quarantine/" + d.Name()); e != nil {
return e
}
}
}
if e = a.scanOrphans(); e != nil {
return e
}
a.extraDisk = 0
e = fs.WalkDir(a.root.FS(), "quarantine", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
st, e := d.Info()
if e == nil {
a.extraDisk += allocated(st)
}
return e
})
if e != nil {
return e
}
_, e = a.db.Exec("PRAGMA wal_checkpoint(PASSIVE)")
return e
}
func (a *App) InvalidateRestored() error {
_, e := a.db.Exec("UPDATE parcels SET state='restored',code_hash=NULL,manage_hash=lower(hex(randomblob(32))),version=version+1; DELETE FROM grants; DELETE FROM admin_sessions; DELETE FROM visitors;")
return e
}
func safeRelative(s string) bool {
return s != "" && !strings.HasPrefix(s, "/") && !strings.Contains(s, "\\") && fs.ValidPath(s)
}
func (a *App) scanOrphans() error {
rows, e := a.db.Query("SELECT id FROM objects")
if e != nil {
return e
}
known := map[string]bool{}
for rows.Next() {
var id string
if e = rows.Scan(&id); e != nil {
rows.Close()
return e
}
known[id] = true
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
return fs.WalkDir(a.root.FS(), "objects", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return errors.New("对象目录存在符号链接")
}
if d.IsDir() {
return nil
}
if !known[d.Name()] {
return a.root.Rename(path, "quarantine/"+randomID())
}
return nil
})
}
+143
View File
@@ -0,0 +1,143 @@
package app
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
)
type RuntimeSettings struct {
FileBudget int64 `json:"file_budget"`
DataBudget int64 `json:"data_budget"`
MinFreeBytes int64 `json:"min_free_bytes"`
AuditRetentionDays int `json:"audit_retention_days"`
AuditMaxRecords int `json:"audit_max_records"`
AuditMaxBytes int64 `json:"audit_max_bytes"`
}
func (c Config) InitialRuntimeSettings() RuntimeSettings {
return RuntimeSettings{c.FileBudget, c.DataBudget, c.MinFreeBytes, c.AuditRetentionDays, c.AuditMaxRecords, c.AuditMaxBytes}
}
func (s RuntimeSettings) Validate() error {
if s.FileBudget < 1 || s.FileBudget > 1<<50 || s.DataBudget > 1<<51 || s.DataBudget-s.FileBudget < 64<<20 || s.MinFreeBytes < 0 || s.MinFreeBytes > 1<<50 {
return errors.New("文件预算须为正数,数据预算须另留至少64MiB维护空间")
}
if s.AuditRetentionDays < 1 || s.AuditRetentionDays > 3650 || s.AuditMaxRecords < 1 || s.AuditMaxRecords > 1000000 || s.AuditMaxBytes < 1024 || s.AuditMaxBytes > 1<<30 || s.AuditMaxBytes > s.DataBudget-s.FileBudget {
return errors.New("审计需保留1–3650天,条数1–100万,内容预算1KiB–1GiB且不超过维护空间")
}
return nil
}
func readRuntime(q interface{ QueryRow(string, ...any) *sql.Row }) (RuntimeSettings, error) {
var text string
s := RuntimeSettings{}
e := q.QueryRow("SELECT json FROM runtime_settings WHERE id=1").Scan(&text)
if e == nil {
e = json.Unmarshal([]byte(text), &s)
}
if e == nil {
e = s.Validate()
}
return s, e
}
func (a *App) runtimeSettings() (RuntimeSettings, error) { return readRuntime(a.db) }
func (a *App) adminRuntime(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
if r.Method == "GET" {
s, e := a.runtimeSettings()
if e != nil {
a.serverError(w, e)
return
}
respond(w, 200, s)
return
}
var settings RuntimeSettings
if !decode(w, r, &settings) {
return
}
if e := settings.Validate(); e != nil {
fail(w, 400, e.Error())
return
}
a.mu.Lock()
defer a.mu.Unlock()
b, e := json.Marshal(settings)
if e != nil {
a.serverError(w, e)
return
}
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
_, e = tx.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b))
if e == nil {
e = a.trimAuditTx(tx)
}
if e == nil {
e = tx.Commit()
}
if e != nil {
a.serverError(w, e)
return
}
a.audit("runtime.update", "", a.remoteIP(r))
respond(w, 200, settings)
}
// The content budget includes UTF-8 fields and a conservative per-record allowance.
// SQLite reuses freed pages; this budget is not the physical database-file size.
const auditRecordBytes = "length(CAST(action AS BLOB))+length(CAST(target AS BLOB))+length(CAST(source AS BLOB))+96"
func (a *App) initializeAuditUsage() error {
expression := func(prefix string) string {
s := auditRecordBytes
for _, name := range []string{"action", "target", "source"} {
s = strings.ReplaceAll(s, name, prefix+"."+name)
}
return s
}
_, e := a.db.Exec("CREATE TABLE IF NOT EXISTS audit_usage(id INTEGER PRIMARY KEY CHECK(id=1),records INTEGER NOT NULL,content_bytes INTEGER NOT NULL); INSERT OR IGNORE INTO audit_usage SELECT 1,count(*),COALESCE(sum(" + auditRecordBytes + "),0) FROM audit; CREATE TRIGGER IF NOT EXISTS audit_insert_usage AFTER INSERT ON audit BEGIN UPDATE audit_usage SET records=records+1,content_bytes=content_bytes+(" + expression("NEW") + ") WHERE id=1; END; CREATE TRIGGER IF NOT EXISTS audit_delete_usage AFTER DELETE ON audit BEGIN UPDATE audit_usage SET records=records-1,content_bytes=content_bytes-(" + expression("OLD") + ") WHERE id=1; END;")
return e
}
func (a *App) trimAuditTx(tx *sql.Tx) error {
s, e := readRuntime(tx)
if e != nil {
return e
}
if _, e = tx.Exec("DELETE FROM audit WHERE created<?", a.now().Unix()-int64(s.AuditRetentionDays)*86400); e != nil {
return e
}
var count, bytes int64
if e = tx.QueryRow("SELECT records,content_bytes FROM audit_usage WHERE id=1").Scan(&count, &bytes); e != nil {
return e
}
if count > int64(s.AuditMaxRecords) {
if _, e = tx.Exec("DELETE FROM audit WHERE id IN(SELECT id FROM audit ORDER BY id DESC LIMIT -1 OFFSET ?)", s.AuditMaxRecords); e != nil {
return e
}
}
if bytes <= s.AuditMaxBytes {
return nil
}
_, e = tx.Exec("WITH sizes AS (SELECT id,sum("+auditRecordBytes+") OVER(ORDER BY id DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS bytes FROM audit) DELETE FROM audit WHERE id IN(SELECT id FROM sizes WHERE bytes>?)", s.AuditMaxBytes)
return e
}
func (a *App) trimAudit() error {
tx, e := a.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if e = a.trimAuditTx(tx); e != nil {
return e
}
return tx.Commit()
}
+177
View File
@@ -0,0 +1,177 @@
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func loginTestAdmin(t *testing.T, c *testClient) {
t.Helper()
expect(t, c.json(t, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}), 200)
}
func TestAdminRuntimeBudgetsPersistAndProtectExistingFiles(t *testing.T) {
c := fixture(t, false)
expect(t, c.request(t, "GET", "/admin/runtime", nil), 401)
data := []byte("preserved file")
id, files := c.create(t, []string{"a"}, [][]byte{data}, 3, false)
c.upload(t, id, files[0], data)
code := c.publish(t, id)
loginTestAdmin(t, c)
settings, e := c.a.runtimeSettings()
if e != nil {
t.Fatal(e)
}
settings.FileBudget = 1
expect(t, c.json(t, "PUT", "/admin/runtime", settings), 200)
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "new", Size: 1}}}), 507)
c.resolve(t, code)
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 200)
cfg := c.a.cfg
c.a.Close()
c.a.db = nil
c.a.root = nil
c.a.lock = nil
a, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
defer a.Close()
saved, e := a.runtimeSettings()
if e != nil || saved.FileBudget != 1 {
t.Fatal("runtime budget reverted on restart")
}
if scalar(t, a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("reducing quota deleted existing files")
}
}
func TestAuditRetentionRecordsAndUTF8ByteBudget(t *testing.T) {
c := fixture(t, false)
settings, e := c.a.runtimeSettings()
if e != nil {
t.Fatal(e)
}
settings.AuditRetentionDays = 2
settings.AuditMaxRecords = 3
settings.AuditMaxBytes = 1024
b, _ := json.Marshal(settings)
c.a.db.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b))
_, e = c.a.db.Exec("INSERT INTO audit(created,action,target,source) VALUES(?,?,?,?)", c.a.now().Unix()-3*86400, "old", "", "")
if e != nil {
t.Fatal(e)
}
for i := 0; i < 8; i++ {
c.a.audit("new", "target", "203.0.113.1")
}
if scalar(t, c.a, "SELECT count(*) FROM audit") != 3 || scalar(t, c.a, "SELECT count(*) FROM audit WHERE action='old'") != 0 {
t.Fatal("retention/count limits failed")
}
settings.AuditMaxRecords = 100
b, _ = json.Marshal(settings)
c.a.db.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b))
for i := 0; i < 10; i++ {
c.a.audit("utf8", strings.Repeat("鸟", 60), "source")
}
actual := scalar(t, c.a, "SELECT COALESCE(sum("+auditRecordBytes+"),0) FROM audit")
ledger := scalar(t, c.a, "SELECT content_bytes FROM audit_usage WHERE id=1")
if actual != ledger || actual > 1024 {
t.Fatalf("audit bytes actual=%d ledger=%d", actual, ledger)
}
if scalar(t, c.a, "SELECT records FROM audit_usage WHERE id=1") != scalar(t, c.a, "SELECT count(*) FROM audit") {
t.Fatal("audit count ledger incorrect")
}
_, e = c.a.db.Exec("UPDATE audit SET created=?", c.a.now().Unix()-3*86400)
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM audit") != 0 {
t.Fatal("background cleanup did not age audit events")
}
}
func TestAnonymousIdentityAndReceiptHaveIndependentLifetimes(t *testing.T) {
c := fixture(t, false)
base := time.Now()
c.a.now = func() time.Time { return base }
var info struct {
Expires int64 `json:"visitor_expires"`
}
w := c.request(t, "GET", "/policy", nil)
expect(t, w, 200)
json.Unmarshal(w.Body.Bytes(), &info)
if info.Expires != base.Unix()+7*86400 {
t.Fatal("cookie lifetime not seven days")
}
w = c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "a", Size: 3}}, Days: 7, Downloads: 5, Renew: true})
expect(t, w, 201)
var receipt struct {
ID string `json:"id"`
Token string `json:"manage_token"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
json.Unmarshal(w.Body.Bytes(), &receipt)
c.upload(t, receipt.ID, receipt.Files[0].ID, []byte("abc"))
code := c.publish(t, receipt.ID)
c.a.now = func() time.Time { return base.Add(6 * 24 * time.Hour) }
w = c.request(t, "GET", "/policy", nil)
json.Unmarshal(w.Body.Bytes(), &info)
if info.Expires != base.Unix()+7*86400 {
t.Fatal("fixed visitor lifetime unexpectedly extended")
}
c.resolve(t, code)
expect(t, c.request(t, "GET", "/pickup/files/"+receipt.Files[0].ID+"/download", nil), 200)
c.a.now = func() time.Time { return base.Add(8 * 24 * time.Hour) }
expect(t, c.request(t, "GET", "/policy", nil), 200)
w = c.request(t, "GET", "/mine", nil)
expect(t, w, 200)
if strings.TrimSpace(w.Body.String()) != "[]" {
t.Fatal("expired browser identity still owns old list")
}
r := c.makeReceiptRequest("GET", "/uploads/"+receipt.ID, receipt.Token, nil)
response := serveTest(c, r)
expect(t, response, 200)
c.a.now = func() time.Time { return base.Add(31 * 24 * time.Hour) }
if e := c.a.Cleanup(); e != nil {
t.Fatal(e)
}
expect(t, serveTest(c, c.makeReceiptRequest("GET", "/uploads/"+receipt.ID, receipt.Token, nil)), 404)
}
func TestServiceSwitchesDoNotGiveAdminUploadOrPickupBypass(t *testing.T) {
c := fixture(t, false)
data := []byte("content")
id, files := c.create(t, []string{"a"}, [][]byte{data}, 3, false)
c.upload(t, id, files[0], data)
c.resolve(t, c.publish(t, id))
loginTestAdmin(t, c)
policy, e := c.a.policy()
if e != nil {
t.Fatal(e)
}
policy.AnonymousUpload = false
policy.AnonymousDownload = false
expect(t, c.json(t, "PUT", "/admin/policy", policy), 200)
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "a", Size: 1}}}), 403)
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 403)
expect(t, c.request(t, "GET", "/admin/stats", nil), 200)
}
func (c *testClient) makeReceiptRequest(method, path, token string, body []byte) *http.Request {
r := httptest.NewRequest(method, "http://localhost:8080/api/v1"+path, bytes.NewReader(body))
r.RemoteAddr = c.ip + ":80"
r.Header.Set("Origin", "http://localhost:8080")
r.Header.Set("Authorization", "Bearer "+token)
return r
}
func serveTest(c *testClient, r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
return w
}
+254
View File
@@ -0,0 +1,254 @@
package app
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/argon2"
"golang.org/x/time/rate"
)
func randomToken() string {
b := make([]byte, 32)
if _, e := rand.Read(b); e != nil {
panic(e)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func randomID() string {
b := make([]byte, 16)
if _, e := rand.Read(b); e != nil {
panic(e)
}
return hex.EncodeToString(b)
}
func tokenHash(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func (a *App) keyed(s string) string {
h := hmac.New(sha256.New, a.secret)
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}
func pickupCode() string {
b := make([]byte, 10)
if _, e := rand.Read(b); e != nil {
panic(e)
}
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
for i := range b {
b[i] = alphabet[b[i]&31]
}
return string(b)
}
func normalizeCode(s string) string {
s = strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(s), "-", ""))
s = strings.ReplaceAll(s, "O", "0")
s = strings.ReplaceAll(s, "I", "1")
return strings.ReplaceAll(s, "L", "1")
}
func hashPassword(p string) (string, error) {
if len(p) < 8 || len(p) > 128 {
return "", errors.New("密码需为 8–128 字节")
}
salt := make([]byte, 16)
if _, e := rand.Read(salt); e != nil {
return "", e
}
h := argon2.IDKey([]byte(p), salt, 2, 32*1024, 1, 32)
return fmt.Sprintf("v1$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(h)), nil
}
func verifyPassword(p, h string) bool {
parts := strings.Split(h, "$")
if len(p) > 128 || len(parts) != 3 || parts[0] != "v1" {
return false
}
salt, e := base64.RawStdEncoding.DecodeString(parts[1])
if e != nil || len(salt) != 16 {
return false
}
want, e := base64.RawStdEncoding.DecodeString(parts[2])
if e != nil || len(want) != 32 {
return false
}
got := argon2.IDKey([]byte(p), salt, 2, 32*1024, 1, 32)
return subtle.ConstantTimeCompare(want, got) == 1
}
func (a *App) remoteIP(r *http.Request) string {
host, _, e := net.SplitHostPort(r.RemoteAddr)
if e != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return "unknown"
}
trusted := func(v net.IP) bool {
for _, n := range a.proxies {
if n.Contains(v) {
return true
}
}
return false
}
if trusted(ip) {
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
if len(parts) <= 16 {
for i := len(parts) - 1; i >= 0; i-- {
candidate := net.ParseIP(strings.TrimSpace(parts[i]))
if candidate == nil {
break
}
ip = candidate
if !trusted(ip) {
break
}
}
}
}
if v := ip.To4(); v != nil {
return v.String()
}
return ip.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
func (a *App) cookie(w http.ResponseWriter, r *http.Request, name, value, path string, seconds int) {
state := a.accessFor(r)
if state.httpCookie {
name += "_http"
}
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: path, MaxAge: seconds, HttpOnly: true, Secure: strings.HasPrefix(state.base, "https://"), SameSite: http.SameSiteStrictMode})
}
func (a *App) visitor(w http.ResponseWriter, r *http.Request) (string, error) {
now := a.now().Unix()
if token := cookieValue(r, "bb_visitor"); token != "" && len(token) < 128 {
var id string
e := a.db.QueryRow("SELECT id FROM visitors WHERE token_hash=? AND expires>?", tokenHash(token), now).Scan(&id)
if e == nil {
return id, nil
}
}
var count int
if e := a.db.QueryRow("SELECT count(*) FROM visitors").Scan(&count); e != nil {
return "", e
}
if count >= a.cfg.MaxRecords {
return "", errors.New("匿名会话已满,请稍后重试")
}
id, t := randomID(), randomToken()
_, e := a.db.Exec("INSERT INTO visitors(id,token_hash,expires) VALUES(?,?,?)", id, tokenHash(t), now+7*86400)
if e == nil {
a.cookie(w, r, "bb_visitor", t, "/api/v1", 7*86400)
}
return id, e
}
func (a *App) currentVisitor(r *http.Request) string {
token := cookieValue(r, "bb_visitor")
if token == "" {
return ""
}
var id string
a.db.QueryRow("SELECT id FROM visitors WHERE token_hash=? AND expires>?", tokenHash(token), a.now().Unix()).Scan(&id)
return id
}
func (a *App) isAdmin(r *http.Request) bool {
token := cookieValue(r, "bb_admin")
if token == "" {
return false
}
var n int
e := a.db.QueryRow("SELECT count(*) FROM admin_sessions WHERE token_hash=? AND expires>?", tokenHash(token), a.now().Unix()).Scan(&n)
return e == nil && n == 1
}
type rateEntry struct {
limiter *rate.Limiter
touched time.Time
}
func (a *App) allow(r *http.Request, kind string, perMinute int) bool {
a.rateMu.Lock()
defer a.rateMu.Unlock()
now := a.now()
key := kind + ":" + a.remoteIP(r)
entry := a.rates[key]
if entry == nil {
if len(a.rates) >= 10000 {
for k, v := range a.rates {
if now.Sub(v.touched) > 10*time.Minute {
delete(a.rates, k)
}
}
if len(a.rates) >= 10000 {
return false
}
}
entry = &rateEntry{limiter: rate.NewLimiter(rate.Limit(float64(perMinute)/60), max(2, perMinute/3))}
a.rates[key] = entry
}
entry.touched = now
return entry.limiter.Allow()
}
func (a *App) acquire(kind, ip string) bool {
a.transferMu.Lock()
defer a.transferMu.Unlock()
limit, perIP := a.cfg.MaxUploads, a.cfg.MaxIPUploads
if kind == "download" {
limit, perIP = a.cfg.MaxDownloads, a.cfg.MaxIPDownloads
}
if a.transfers[kind] >= limit || a.transfers[kind+":"+ip] >= perIP {
return false
}
a.transfers[kind]++
a.transfers[kind+":"+ip]++
return true
}
func (a *App) release(kind, ip string) {
a.transferMu.Lock()
defer a.transferMu.Unlock()
a.transfers[kind]--
a.transfers[kind+":"+ip]--
if a.transfers[kind+":"+ip] == 0 {
delete(a.transfers, kind+":"+ip)
}
}
func (a *App) deadlines(w http.ResponseWriter, r *http.Request) {
c := http.NewResponseController(w)
end := time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second)
_ = c.SetReadDeadline(end)
_ = c.SetWriteDeadline(end)
}
func cookieValue(r *http.Request, name string) string {
for _, key := range cookieNames(r, name) {
if c, err := r.Cookie(key); err == nil && c.Value != "" {
return c.Value
}
}
return ""
}
func parseInt(s string) int { v, _ := strconv.Atoi(s); return v }
type idleReader struct {
reader io.Reader
controller *http.ResponseController
total time.Time
idle time.Duration
}
func (r *idleReader) Read(p []byte) (int, error) {
end := time.Now().Add(r.idle)
if end.After(r.total) {
end = r.total
}
_ = r.controller.SetReadDeadline(end)
return r.reader.Read(p)
}
+678
View File
@@ -0,0 +1,678 @@
package app
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unicode"
"filippo.io/age"
"golang.org/x/sys/unix"
)
type ManifestFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
type Manifest struct {
Files []ManifestFile `json:"files"`
Days int `json:"days"`
Downloads int `json:"downloads"`
Password string `json:"password"`
Renew bool `json:"renew"`
}
func reserveSize(n int64, encrypted bool) int64 {
if encrypted {
n += 1024 + ((n/65536)+1)*16
}
return ((n+4095)/4096)*4096 + 4096
}
func cleanName(s string) string {
s = strings.ReplaceAll(s, "\\", "/")
s = filepath.Base(s)
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
return strings.TrimSpace(s)
}
func objectPath(id string) string { return "objects/" + id[:2] + "/" + id }
func (a *App) capacity() (used, reserved, metadata int64, err error) {
err = a.db.QueryRow("SELECT COALESCE(sum(stored_bytes),0) FROM objects").Scan(&used)
if err != nil {
return
}
err = a.db.QueryRow("SELECT COALESCE(sum(reserved),0) FROM files WHERE object_id IS NULL AND reserved>0 AND status!='deleted'").Scan(&reserved)
if err != nil {
return
}
for _, p := range []string{"db/bluebird.db", "db/bluebird.db-wal", "db/bluebird.db-shm"} {
s, e := a.root.Stat(p)
if e == nil {
metadata += allocated(s)
} else if !errors.Is(e, os.ErrNotExist) {
err = e
return
}
}
metadata += a.extraDisk
return
}
func allocated(s os.FileInfo) int64 {
if st, ok := s.Sys().(*syscall.Stat_t); ok {
return max(st.Blocks*512, s.Size())
}
if st, ok := s.Sys().(*unix.Stat_t); ok {
return max(st.Blocks*512, s.Size())
}
return ((s.Size() + 4095) / 4096) * 4096
}
func (a *App) createUpload(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "upload", 20) {
fail(w, 429, "上传请求过于频繁")
return
}
var m Manifest
if !decode(w, r, &m) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
if !p.AnonymousUpload {
fail(w, 403, "管理员已关闭上传")
return
}
if m.Days == 0 {
m.Days = p.DefaultDays
}
if m.Downloads == 0 {
m.Downloads = p.DefaultDownloads
}
if m.Days < 1 || m.Days > p.MaxInitialDays || m.Downloads < 1 || m.Downloads > p.MaxDownloads || len(m.Files) < 1 || len(m.Files) > p.MaxFiles {
fail(w, 400, "文件数量、期限或下载次数超出策略")
return
}
if p.RequirePassword && m.Password == "" {
fail(w, 400, "当前策略要求取件密码")
return
}
hash := ""
if m.Password != "" {
if len(m.Password) < 8 || len(m.Password) > 128 {
fail(w, 400, "取件密码需为 8–128 字节")
return
}
select {
case a.passwordSlots <- struct{}{}:
default:
fail(w, 429, "密码验证繁忙")
return
}
hash, e = hashPassword(m.Password)
<-a.passwordSlots
if e != nil {
fail(w, 400, e.Error())
return
}
}
var total, reservation int64
for i, f := range m.Files {
m.Files[i].Name = cleanName(f.Name)
if m.Files[i].Name == "" || m.Files[i].Name == "." || m.Files[i].Name == ".." || len(m.Files[i].Name) > 240 || f.Size < 0 || f.Size > p.MaxFileBytes || total > p.MaxBatchBytes-f.Size {
fail(w, 400, "文件名、文件大小或批次总大小无效")
return
}
total += f.Size
reservation += reserveSize(f.Size, a.cfg.Encryption)
}
if total > p.MaxBatchBytes {
fail(w, 400, "批次总大小超出限制")
return
}
var count int
if e = a.db.QueryRow("SELECT count(*) FROM files").Scan(&count); e != nil {
a.serverError(w, e)
return
}
if count+len(m.Files) > a.cfg.MaxRecords {
fail(w, 507, "文件记录已满")
return
}
used, reserved, meta, e := a.capacity()
if e != nil {
a.serverError(w, e)
return
}
var disk unix.Statfs_t
if e = unix.Statfs(a.cfg.DataDir, &disk); e != nil {
a.serverError(w, e)
return
}
free := int64(disk.Bavail) * int64(disk.Bsize)
limits, e := a.runtimeSettings()
if e != nil {
a.serverError(w, e)
return
}
if used+reserved+reservation > limits.FileBudget || used+reserved+reservation+meta > limits.DataBudget || free-reserved-reservation < limits.MinFreeBytes || (disk.Files > 0 && disk.Ffree < uint64(len(m.Files)+32)) {
fail(w, 507, "可用存储不足,暂时无法接收文件")
return
}
visitor, e := a.visitor(w, r)
if e != nil {
fail(w, 429, "匿名会话暂不可用")
return
}
ip := a.remoteIP(r)
day := a.now().UTC().Format("2006-01-02")
subjects := []string{"ip:" + a.keyed(ip), "visitor:" + visitor}
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
for i, subject := range subjects {
if _, e = tx.Exec("INSERT OR IGNORE INTO usage VALUES(?,?,0,0)", subject, day); e != nil {
a.serverError(w, e)
return
}
var b int64
var n int
if e = tx.QueryRow("SELECT bytes,uploads FROM usage WHERE subject=? AND day=?", subject, day).Scan(&b, &n); e != nil {
a.serverError(w, e)
return
}
limit := p.IPDailyUploads
if i == 1 {
limit = p.SessionDailyUploads
}
if n >= limit || (i == 0 && b > p.IPDailyBytes-total) {
fail(w, 429, "今日上传次数或流量额度已用完")
return
}
if _, e = tx.Exec("UPDATE usage SET bytes=bytes+?,uploads=uploads+1 WHERE subject=? AND day=?", total, subject, day); e != nil {
a.serverError(w, e)
return
}
}
id, manage := randomID(), randomToken()
now := a.now().Unix()
renewal := 0
if m.Renew {
renewal = p.RenewalDays
}
_, e = tx.Exec("INSERT INTO parcels(id,visitor,ip,state,created,password_hash,manage_hash,days,downloads,renewal_days,renewal_mode,lifetime_days,upload_expires,usage_day) VALUES(?,?,?,'uploading',?,?,?,?,?,?,?,?,?,?)", id, visitor, ip, now, hash, tokenHash(manage), m.Days, m.Downloads, renewal, p.RenewalMode, p.MaxLifetimeDays, now+3600, day)
if e != nil {
a.serverError(w, e)
return
}
out := []map[string]any{}
for _, f := range m.Files {
fid := randomID()
if _, e = tx.Exec("INSERT INTO files(id,parcel,name,size,status,reserved,remaining,charged) VALUES(?,?,?,?,'pending',?,?,?)", fid, id, f.Name, f.Size, reserveSize(f.Size, a.cfg.Encryption), m.Downloads, f.Size); e != nil {
a.serverError(w, e)
return
}
out = append(out, map[string]any{"id": fid, "name": f.Name, "size": f.Size})
}
if e = tx.Commit(); e != nil {
a.serverError(w, e)
return
}
a.audit("upload.create", id, ip)
respond(w, 201, map[string]any{"id": id, "manage_token": manage, "files": out})
}
func (a *App) owner(r *http.Request, id string) bool {
var owner, manage string
if e := a.db.QueryRow("SELECT visitor,manage_hash FROM parcels WHERE id=?", id).Scan(&owner, &manage); e != nil {
return false
}
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token != "" && tokenHash(token) == manage {
return true
}
v := a.currentVisitor(r)
return v != "" && v == owner
}
func (a *App) uploadFile(w http.ResponseWriter, r *http.Request) {
id, fid := r.PathValue("parcel"), r.PathValue("file")
ip := a.remoteIP(r)
if !a.allow(r, "file-transfer", 120) {
fail(w, 429, "文件传输请求过于频繁")
return
}
if !a.acquire("upload", ip) {
fail(w, 429, "上传并发已满,请稍后重试")
return
}
defer a.release("upload", ip)
a.deadlines(w, r)
a.mu.Lock()
if !a.owner(r, id) {
a.mu.Unlock()
fail(w, 404, "上传会话不存在")
return
}
var state, status string
var expected, reserved, expiry int64
e := a.db.QueryRow("SELECT p.state,f.status,f.size,f.reserved,p.upload_expires FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=? AND p.id=?", fid, id).Scan(&state, &status, &expected, &reserved, &expiry)
if e != nil || state != "uploading" || expiry <= a.now().Unix() {
a.mu.Unlock()
fail(w, 409, "上传会话已失效")
return
}
if status == "ready" {
a.mu.Unlock()
respond(w, 200, map[string]bool{"ready": true})
return
}
if a.activeFiles[fid] {
a.mu.Unlock()
fail(w, 409, "该文件正在上传")
return
}
var attempts int
var sessionIP, visitor, day string
if e = a.db.QueryRow("SELECT f.attempts,p.ip,p.visitor,p.usage_day FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=?", fid).Scan(&attempts, &sessionIP, &visitor, &day); e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
policy, pe := a.policy()
if pe != nil {
a.mu.Unlock()
a.serverError(w, pe)
return
}
if !policy.AnonymousUpload {
a.mu.Unlock()
fail(w, 403, "上传已关闭")
return
}
tx, e := a.db.Begin()
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
if attempts > 0 {
var charged int64
e = tx.QueryRow("SELECT bytes FROM usage WHERE subject=? AND day=?", "ip:"+a.keyed(sessionIP), day).Scan(&charged)
if e == nil && charged > policy.IPDailyBytes-expected {
tx.Rollback()
a.mu.Unlock()
fail(w, 429, "重试上传超过日流量限制")
return
}
if e == nil {
for _, subject := range []string{"ip:" + a.keyed(sessionIP), "visitor:" + visitor} {
_, e = tx.Exec("UPDATE usage SET bytes=bytes+? WHERE subject=? AND day=?", expected, subject, day)
if e != nil {
break
}
}
}
if e == nil {
_, e = tx.Exec("UPDATE files SET charged=charged+? WHERE id=?", expected, fid)
}
}
if e == nil {
_, e = tx.Exec("UPDATE files SET attempts=attempts+1 WHERE id=?", fid)
}
if e == nil {
e = tx.Commit()
} else {
tx.Rollback()
}
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
a.activeFiles[fid] = true
a.mu.Unlock()
defer func() { a.mu.Lock(); delete(a.activeFiles, fid); a.mu.Unlock() }()
tmp := "tmp/" + fid
_ = a.root.Remove(tmp)
f, e := a.root.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
a.serverError(w, e)
return
}
defer a.root.Remove(tmp)
var dst io.Writer = f
var enc io.WriteCloser
if a.cfg.Encryption {
enc, e = age.Encrypt(f, a.identity.Recipient())
if e != nil {
f.Close()
a.serverError(w, e)
return
}
dst = enc
}
h := sha256.New()
r.Body = http.MaxBytesReader(w, r.Body, expected)
totalDeadline := time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second)
if sessionEnd := time.Unix(expiry, 0); sessionEnd.Before(totalDeadline) {
totalDeadline = sessionEnd
}
src := &idleReader{reader: r.Body, controller: http.NewResponseController(w), total: totalDeadline, idle: time.Duration(a.cfg.TransferIdleSeconds) * time.Second}
received, e := io.CopyBuffer(io.MultiWriter(dst, h), src, make([]byte, 128<<10))
if e == nil && received != expected {
e = errors.New("实际大小与声明不一致")
}
if e == nil && enc != nil {
e = enc.Close()
}
if e == nil {
e = f.Sync()
}
st, se := f.Stat()
ce := f.Close()
if e == nil {
e = ce
}
if e == nil {
e = se
}
a.mu.Lock()
defer a.mu.Unlock()
if _, ue := a.db.Exec("UPDATE files SET received=received+? WHERE id=?", received, fid); ue != nil {
a.serverError(w, ue)
return
}
if e != nil {
fail(w, 400, "文件接收失败或大小与声明不一致,请重试")
return
}
stored := allocated(st)
if stored > reserved {
fail(w, 507, "文件实际落盘开销超出预留")
return
}
if e = a.db.QueryRow("SELECT state FROM parcels WHERE id=?", id).Scan(&state); e != nil || state != "uploading" || expiry <= a.now().Unix() {
fail(w, 409, "上传已取消")
return
}
keyID := "plain"
if a.cfg.Encryption {
keyID = tokenHash(a.identity.Recipient().String())
}
digest := a.keyed(hex.EncodeToString(h.Sum(nil)) + "/" + keyID)
if !a.cfg.Deduplication {
digest += "/" + randomID()
}
var oid string
e = a.db.QueryRow("SELECT id FROM objects WHERE digest=? AND size=?", digest, expected).Scan(&oid)
if e != nil && !errors.Is(e, sql.ErrNoRows) {
a.serverError(w, e)
return
}
newObject := oid == ""
committed := false
defer func() {
if newObject && oid != "" && !committed {
if de := a.root.Remove(objectPath(oid)); de != nil && !errors.Is(de, os.ErrNotExist) {
a.extraDisk += stored
a.log.Error("orphan removal failed", "error", de)
}
}
}()
if newObject {
oid = randomID()
path := objectPath(oid)
if e = a.root.MkdirAll(filepath.Dir(path), 0700); e != nil {
a.serverError(w, e)
return
}
if e = a.root.Rename(tmp, path); e != nil {
a.serverError(w, e)
return
}
if e = syncDir(a.root, filepath.Dir(path)); e != nil {
a.serverError(w, e)
return
}
if e = syncDir(a.root, "tmp"); e != nil {
a.serverError(w, e)
return
}
} else {
if _, e = a.root.Stat(objectPath(oid)); e != nil {
a.serverError(w, e)
return
}
if e = a.root.Remove(tmp); e != nil {
a.serverError(w, e)
return
}
}
tx, e = a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
if newObject {
_, e = tx.Exec("INSERT INTO objects VALUES(?,?,?,?,?,?,?)", oid, digest, expected, stored, a.cfg.Encryption, keyID, a.now().Unix())
if e != nil {
_ = a.root.Remove(objectPath(oid))
a.serverError(w, e)
return
}
}
var attached sql.Result
attached, e = tx.Exec("UPDATE files SET object_id=?,status='ready',reserved=0 WHERE id=? AND status='pending'", oid, fid)
if e == nil {
n, _ := attached.RowsAffected()
if n != 1 {
e = errors.New("文件已被管理员删除")
}
}
if e == nil {
e = tx.Commit()
}
if e != nil {
if newObject {
_ = a.root.Remove(objectPath(oid))
}
a.serverError(w, e)
return
}
committed = true
respond(w, 200, map[string]bool{"ready": true})
}
func (a *App) publish(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var state string
var days, lifetime int
var uploadExpiry int64
e := a.db.QueryRow("SELECT state,days,lifetime_days,upload_expires FROM parcels WHERE id=?", id).Scan(&state, &days, &lifetime, &uploadExpiry)
if e != nil {
a.serverError(w, e)
return
}
if state == "ready" {
respond(w, 200, map[string]any{"published": true, "message": "已发布;如丢失取件码,请重新生成"})
return
}
policy, pe := a.policy()
if pe != nil {
a.serverError(w, pe)
return
}
if !policy.AnonymousUpload {
fail(w, 403, "上传已关闭")
return
}
if state != "uploading" || uploadExpiry <= a.now().Unix() {
fail(w, 409, "上传会话已失效")
return
}
var pending int
e = a.db.QueryRow("SELECT count(*) FROM files WHERE parcel=? AND status!='ready'", id).Scan(&pending)
if e != nil {
a.serverError(w, e)
return
}
if pending > 0 {
fail(w, 409, "尚有文件未上传完成")
return
}
now := a.now().Unix()
expires := now + int64(days)*86400
hard := now + int64(lifetime)*86400
code := pickupCode()
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
_, e = tx.Exec("UPDATE parcels SET state='ready',created=?,expires=?,hard_expires=?,code_hash=? WHERE id=?", now, expires, hard, a.keyed(code), id)
if e == nil {
_, e = tx.Exec("UPDATE files SET expires=?,hard_expires=? WHERE parcel=?", expires, hard, id)
}
if e == nil {
e = tx.Commit()
}
if e != nil {
a.serverError(w, e)
return
}
if e = a.settleUsage(id); e != nil {
a.serverError(w, e)
return
}
a.audit("parcel.publish", id, a.remoteIP(r))
respond(w, 200, a.shareResult(r, code, expires))
}
func (a *App) shareResult(r *http.Request, code string, expires int64) map[string]any {
links := []map[string]string{}
for _, base := range a.cfg.accessOrigins() {
label := "公网HTTP"
if strings.HasPrefix(base, "https://") {
label = "公网HTTPS"
}
u, _ := url.Parse(base)
ip := net.ParseIP(u.Hostname())
if ip != nil && (ip.IsPrivate() || ip.IsLoopback()) {
label = "内网入口"
}
links = append(links, map[string]string{"label": label + " · " + base, "url": base + "/#/pickup/" + code})
}
return map[string]any{"code": code, "url": a.accessFor(r).base + "/#/pickup/" + code, "urls": links, "expires": expires}
}
func (a *App) regenerate(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var expiry int64
if e := a.db.QueryRow("SELECT expires FROM parcels WHERE id=? AND state='ready' AND EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0 AND expires>?)", id, a.now().Unix()).Scan(&expiry); e != nil || expiry <= a.now().Unix() {
fail(w, 409, "包裹不可取件")
return
}
code := pickupCode()
if _, e := a.db.Exec("UPDATE parcels SET code_hash=?,version=version+1 WHERE id=?", a.keyed(code), id); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, a.shareResult(r, code, expiry))
}
func (a *App) revoke(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
if _, e := a.db.Exec("UPDATE parcels SET state='revoked',version=version+1 WHERE id=?", id); e != nil {
a.serverError(w, e)
return
}
a.audit("parcel.revoke", id, a.remoteIP(r))
respond(w, 200, map[string]bool{"revoked": true})
}
func (a *App) uploadStatus(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var state string
var created, expires int64
if e := a.db.QueryRow("SELECT state,created,expires FROM parcels WHERE id=?", id).Scan(&state, &created, &expires); e != nil {
a.serverError(w, e)
return
}
files, e := a.fileList(id)
if e != nil {
a.serverError(w, e)
return
}
respond(w, 200, map[string]any{"id": id, "state": state, "created": created, "expires": expires, "files": files})
}
func (a *App) mine(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
v := a.currentVisitor(r)
if v == "" {
respond(w, 200, []any{})
return
}
rows, e := a.db.Query("SELECT p.id,p.state,p.created,p.expires,(SELECT count(*) FROM files WHERE parcel=p.id),(SELECT COALESCE(sum(size),0) FROM files WHERE parcel=p.id) FROM parcels p WHERE visitor=? ORDER BY created DESC LIMIT 100", v)
if e != nil {
a.serverError(w, e)
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, state string
var created, expires, size int64
var n int
if e = rows.Scan(&id, &state, &created, &expires, &n, &size); e != nil {
a.serverError(w, e)
return
}
out = append(out, map[string]any{"id": id, "state": state, "created": created, "expires": expires, "count": n, "size": size})
}
if e = rows.Err(); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, out)
}
View File
Whitespace-only changes.
+12
View File
@@ -0,0 +1,12 @@
package web
import (
"embed"
"io/fs"
"net/http"
)
//go:embed dist
var assets embed.FS
func Handler() http.Handler { sub, _ := fs.Sub(assets, "dist"); return http.FileServer(http.FS(sub)) }
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$project_root"
build_mode=${1:-build}
case "$build_mode" in build|release|web) ;; *) echo 'Usage: scripts/build.sh [build|release|web] [all|amd64|arm64]' >&2; exit 2;; esac
release_target=${2:-all}
case "$release_target" in all) release_arches='amd64 arm64' ;; amd64|arm64) release_arches=$release_target ;; *) echo 'Release architecture must be all, amd64 or arm64' >&2; exit 2 ;; esac
npm --prefix web ci
npm --prefix web run build
touch internal/web/dist/.gitkeep
if [ "$build_mode" = web ]; then exit 0; fi
node scripts/notices.mjs
mkdir -p build
if [ "$build_mode" = build ]; then
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o build/bluebird ./cmd/bluebird
exit 0
fi
for build_arch in $release_arches; do
build_dir="build/linux-$build_arch"
rm -rf -- "$build_dir"
mkdir -p "$build_dir"
CGO_ENABLED=0 GOOS=linux GOARCH="$build_arch" go build -trimpath -ldflags='-s -w' -o "$build_dir/bluebird" ./cmd/bluebird
cp deploy/config.example.toml "$build_dir/config.toml"
cp deploy/install.sh "$build_dir/install.sh"
cp deploy/upgrade.sh "$build_dir/upgrade.sh"
printf '%s\n' "$build_arch" > "$build_dir/ARCH"
cp -R deploy docs "$build_dir/"
cp README.md THIRD_PARTY_NOTICES.txt "$build_dir/"
tar -czf "build/bluebird-linux-$build_arch.tar.gz" -C "$build_dir" .
done
if [ "$release_target" != arm64 ]; then cp build/linux-amd64/bluebird build/bluebird; fi
(cd build && sha256sum bluebird-linux-*.tar.gz > SHA256SUMS)
+15
View File
@@ -0,0 +1,15 @@
import { chromium } from '../web/node_modules/playwright/index.mjs'
import { readFileSync, writeFileSync } from 'node:fs'
const svg=readFileSync('web/public/favicon.svg','utf8')
const browser=await chromium.launch({headless:true})
try {
const page=await browser.newPage();await page.setContent('<canvas id="icon"></canvas>')
const images=[]
for(const size of [16,32,48,180,192,512]){
const data=await page.evaluate(async({svg,size})=>{const canvas=document.querySelector('canvas');canvas.width=size;canvas.height=size;const image=new Image();image.src='data:image/svg+xml;base64,'+btoa(unescape(encodeURIComponent(svg)));await image.decode();canvas.getContext('2d').drawImage(image,0,0,size,size);return canvas.toDataURL('image/png').split(',')[1]},{svg,size})
const bytes=Buffer.from(data,'base64');if(size<=48)images.push({size,bytes});if(size===180)writeFileSync('web/public/apple-touch-icon.png',bytes);if(size===192||size===512)writeFileSync(`web/public/icon-${size}.png`,bytes)
}
const header=Buffer.alloc(6+16*images.length);header.writeUInt16LE(1,2);header.writeUInt16LE(images.length,4);let offset=header.length
images.forEach(({size,bytes},i)=>{const index=6+16*i;header[index]=size;header[index+1]=size;header.writeUInt16LE(1,index+4);header.writeUInt16LE(32,index+6);header.writeUInt32LE(bytes.length,index+8);header.writeUInt32LE(offset,index+12);offset+=bytes.length})
writeFileSync('web/public/favicon.ico',Buffer.concat([header,...images.map(image=>image.bytes)]))
} finally{await browser.close()}
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
# Only run inside a disposable container with /pkg mounted read-only.
set -eu
[ "${BLUEBIRD_INSTALL_SMOKE:-}" = 1 ] && [ "$(id -u)" = 0 ] && [ -f /pkg/ARCH ] || { echo 'Only for the isolated deployment test container' >&2; exit 1; }
fixture_password=install-fixture-password
worker_pid=
trap 'if [ -n "$worker_pid" ]; then kill "$worker_pid" 2>/dev/null || :; wait "$worker_pid" 2>/dev/null || :; fi' EXIT
/pkg/bluebird validate-config --config /pkg/config.toml
[ ! -e /pkg/var ]
# Invalid site paths must fail before publishing configuration or credentials.
if BLUEBIRD_ADMIN_PASSWORD=$fixture_password /pkg/install.sh --prefix /opt/rejected --url http://files.example.test/path --encrypt --no-start >/tmp/rejected.log 2>&1; then echo 'Invalid site path accepted' >&2; exit 1; fi
[ ! -e /opt/rejected ]
[ -z "$(find /opt -maxdepth 1 -name '.bluebird-install-*' -print)" ]
if [ "${1:-https}" = http ]; then
BLUEBIRD_ADMIN_PASSWORD=$fixture_password /pkg/install.sh --url http://files.example.test --encrypt --no-start
grep -q '^listen = "127.0.0.1:8080"$' /opt/bluebird/config.toml
grep -q '^secure_cookies = false$' /opt/bluebird/config.toml
runuser -u bluebird -- /opt/bluebird/bluebird check --config /opt/bluebird/config.toml
echo 'Public HTTP hostname without a port installed successfully'
exit 0
fi
if [ "${1:-https}" = lan ]; then
if BLUEBIRD_ADMIN_PASSWORD=$fixture_password /pkg/install.sh --prefix /opt/rejected --url http://192.168.1.10 --lan-http --no-start >/tmp/rejected-no-port.log 2>&1; then echo 'Portless LAN HTTP accepted' >&2; exit 1; fi
grep -q '内网HTTP地址须包含端口' /tmp/rejected-no-port.log
if BLUEBIRD_ADMIN_PASSWORD=$fixture_password /pkg/install.sh --prefix /opt/rejected --url http://192.168.1.10:8080 --no-start >/tmp/rejected-lan.log 2>&1; then echo 'LAN HTTP accepted without explicit flag' >&2; exit 1; fi
BLUEBIRD_ADMIN_PASSWORD=$fixture_password /pkg/install.sh --url http://192.168.1.10:8080 --lan-http --encrypt --no-start
grep -q '^listen = "192.168.1.10:8080"$' /opt/bluebird/config.toml
grep -q '^allow_lan_http = true$' /opt/bluebird/config.toml
grep -q '^secure_cookies = false$' /opt/bluebird/config.toml
echo 'LAN installation and explicit HTTP opt-in passed'
exit 0
fi
# A failed password prompt/init leaves no final data directory or stale stage.
if BLUEBIRD_ADMIN_PASSWORD=short /pkg/install.sh --prefix /opt/rejected --url https://files.example.com --no-start >/tmp/rejected-password.log 2>&1; then echo 'Short password accepted' >&2; exit 1; fi
[ ! -e /opt/rejected ]
[ -z "$(find /opt -maxdepth 1 -name '.bluebird-install-*' -print)" ]
BLUEBIRD_ADMIN_PASSWORD=$fixture_password /pkg/install.sh --prefix /opt/bluebird-fixture --lan-url http://127.0.0.1:18995 --access-url http://files.example.com --url https://files.example.com --encrypt --no-start
prefix=/opt/bluebird-fixture
[ "$(stat -c '%U:%G:%a' "$prefix")" = root:bluebird:750 ]
[ "$(stat -c '%U:%G:%a' "$prefix/bluebird")" = root:bluebird:750 ]
[ "$(stat -c '%U:%G:%a' "$prefix/config.toml")" = root:bluebird:640 ]
[ "$(stat -c '%U:%G:%a' "$prefix/var")" = bluebird:bluebird:700 ]
grep -q '^public_url = .*https://files.example.com' "$prefix/config.toml"
grep -q '^secure_cookies = true$' "$prefix/config.toml"
grep -q 'http://files.example.com' "$prefix/config.toml"
grep -q 'http://127.0.0.1:18995' "$prefix/config.toml"
grep -q '^allow_lan_http = true$' "$prefix/config.toml"
grep -q '^encryption = true$' "$prefix/config.toml"
grep -q "^ExecStart=$prefix/bluebird serve --config $prefix/config.toml$" /etc/systemd/system/bluebird.service
[ -f "$prefix/var/secrets/age.key" ]
if grep -raq "$fixture_password" "$prefix"; then echo 'Plaintext bootstrap password persisted' >&2; exit 1; fi
before=$(sha256sum "$prefix/config.toml")
if BLUEBIRD_ADMIN_PASSWORD=another-password /pkg/install.sh --prefix "$prefix" --url https://other.example.com --no-start >/tmp/reinstall.log 2>&1; then echo 'Existing installation overwritten' >&2; exit 1; fi
[ "$(sha256sum "$prefix/config.toml")" = "$before" ]
runuser -u bluebird -- "$prefix/bluebird" check --config "$prefix/config.toml"
runuser -u bluebird -- "$prefix/bluebird" serve --config "$prefix/config.toml" >/tmp/bluebird-serve.log 2>&1 &
worker_pid=$!
ready=false
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if bash -c 'exec 3<>/dev/tcp/127.0.0.1/18995; printf "GET /api/v1/health HTTP/1.1\r\nHost: files.example.com\r\nConnection: close\r\n\r\n" >&3; cat <&3' >/tmp/health.txt 2>/dev/null && grep -q '"ready":true' /tmp/health.txt; then ready=true; break; fi
sleep .1
done
[ "$ready" = true ] || { cat /tmp/bluebird-serve.log; exit 1; }
# Config validation is read-only and may run while the service holds its lock.
runuser -u bluebird -- "$prefix/bluebird" validate-config --config "$prefix/config.toml"
if runuser -u bluebird -- "$prefix/bluebird" check --config "$prefix/config.toml" >/tmp/locked-check.log 2>&1; then echo 'Maintenance ignored service lock' >&2; exit 1; fi
kill "$worker_pid"
wait "$worker_pid" || :
worker_pid=
echo 'Installation, ownership, protected credentials, repeat-install guard and native service passed'
+11
View File
@@ -0,0 +1,11 @@
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { execFileSync } from 'node:child_process'
const notices=['蓝鸟第三方组件许可声明\nGenerated from pinned dependency sources. Some development/test dependencies are included.']
function include(label,dir){if(!dir)return;let files;try{files=readdirSync(dir)}catch{return}for(const file of files){if(/^(LICENSE|COPYING)([.\-_]|$)/i.test(file)){try{notices.push(`\n${'='.repeat(72)}\n${label} / ${file}\n${readFileSync(join(dir,file),'utf8')}`)}catch{}}}}
const modules=execFileSync('go',['list','-m','-json','all'],{encoding:'utf8'}).trim().split(/\n(?=\{)/).map(s=>JSON.parse(s))
for(const module of modules){if(!module.Main)include(`${module.Path}@${module.Version}`,module.Dir)}
include('Go runtime / standard library',execFileSync('go',['env','GOROOT'],{encoding:'utf8'}).trim())
const lock=JSON.parse(readFileSync('web/package-lock.json','utf8'))
for(const [path,entry] of Object.entries(lock.packages)){if(path.startsWith('node_modules/'))include(`${path.slice(13)}@${entry.version}`,join('web',path))}
writeFileSync('THIRD_PARTY_NOTICES.txt',notices.join('\n'))
+109
View File
@@ -0,0 +1,109 @@
"""Verify the native release in an isolated directory on the workspace filesystem."""
import http.cookiejar
import json
import os
from pathlib import Path
import secrets
import shutil
import socket
import sqlite3
import subprocess
import tarfile
import tempfile
import time
import urllib.request
project = Path(__file__).resolve().parent.parent
stage = Path(tempfile.mkdtemp(prefix=".bluebird-smoke-", dir=project))
server = None
try:
with tarfile.open(project / "build/bluebird-linux-amd64.tar.gz") as archive:
archive.extractall(stage, filter="data")
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
base = f"http://127.0.0.1:{port}"
config = stage / "config.toml"
source = (config.read_text().replace("127.0.0.1:8080", f"127.0.0.1:{port}")
.replace("http://localhost:8080", base)
.replace("min_free_bytes = 1073741824", "min_free_bytes = 0")
.replace("encryption = false", "encryption = true"))
config.write_text(source)
binary = str(stage / "bluebird")
password = secrets.token_urlsafe(24)
env = os.environ.copy()
env["BLUEBIRD_ADMIN_PASSWORD"] = password
subprocess.run([binary, "init", "--config", str(config)], env=env, check=True,
capture_output=True, timeout=15)
server = subprocess.Popen([binary, "serve", "--config", str(config)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def call(method, path, body=None, raw=False):
headers = {}
if body is not None:
if not raw:
body = json.dumps(body).encode()
headers = {"Origin": base, "Content-Type":
"application/octet-stream" if raw else "application/json"}
request = urllib.request.Request(base + "/api/v1" + path, data=body,
headers=headers, method=method)
with opener.open(request, timeout=10) as response:
return json.loads(response.read())
for _ in range(50):
try:
assert call("GET", "/health")["ready"]
break
except (OSError, AssertionError):
time.sleep(.1)
else:
raise AssertionError("Release server did not start")
with opener.open(base + "/", timeout=10) as response:
assert b'<div id="app">' in response.read()
call("GET", "/policy")
payload = b"bluebird-release-smoke\n" * 4096
parcel = call("POST", "/uploads", {
"files": [{"name": "first.dat", "size": len(payload)},
{"name": "same.dat", "size": len(payload)}],
"days": 7, "downloads": 2, "renew": True, "password": ""})
for file in parcel["files"]:
call("PUT", f"/uploads/{parcel['id']}/files/{file['id']}", payload, True)
share = call("POST", f"/uploads/{parcel['id']}/publish", {})
call("POST", "/pickup/resolve", {"code": share["code"]})
request = urllib.request.Request(
base + f"/api/v1/pickup/files/{parcel['files'][0]['id']}/download")
with opener.open(request, timeout=10) as response:
assert response.read() == payload
call("POST", "/admin/login", {"username": "admin", "password": password})
stats = call("GET", "/admin/stats")
assert stats["objects"] == 1 and stats["files"] == 2
server.terminate()
server.wait(timeout=10)
server = None
subprocess.run([binary, "check", "--config", str(config)], check=True,
capture_output=True, timeout=15)
backup = stage / "backup.tar.gz"
subprocess.run([binary, "backup", "--config", str(config), "--output", str(backup)],
check=True, capture_output=True, timeout=15)
restored = stage / "restore.toml"
restored.write_text(source.replace('data_dir = "var"', 'data_dir = "restored-var"'))
subprocess.run([binary, "restore", "--config", str(restored), "--input", str(backup)],
check=True, capture_output=True, timeout=15)
subprocess.run([binary, "check", "--config", str(restored)], check=True,
capture_output=True, timeout=15)
with sqlite3.connect(stage / "restored-var/db/bluebird.db") as db:
assert db.execute("SELECT state,code_hash FROM parcels").fetchone() == ("restored", None)
assert db.execute("SELECT count(*) FROM grants").fetchone()[0] == 0
assert db.execute("SELECT count(*) FROM objects").fetchone()[0] == 1
print("发布包验证通过:初始化、内嵌前端、匿名去重、加密下载、管理、检查、备份恢复。")
finally:
if server is not None:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
shutil.rmtree(stage)
+103
View File
@@ -0,0 +1,103 @@
"""Exercise a real previous installation inside a disposable rootless container.
Requires podman, the old tarball argument and the newly built amd64 release.
"""
import http.cookies
import json
from pathlib import Path
import secrets
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.request
project = Path(__file__).resolve().parent.parent
name = 'bluebird-upgrade-' + secrets.token_hex(4)
password = 'upgrade-preserved-password'
base = 'http://127.0.0.1:18996'
origin = 'https://files.example.test'
def run(*args, **kw):
return subprocess.run(args, check=True, capture_output=True, text=True, **kw).stdout
def inside(*args, **kw):
return run('podman', 'exec', name, *args, **kw)
with tempfile.TemporaryDirectory(prefix='bluebird-upgrade-old-') as old:
with tarfile.open(sys.argv[1]) as archive:
archive.extractall(old, filter='data')
try:
run('podman', 'run', '--rm', '-d', '--security-opt', 'label=disable', '--name', name,
'-p', '127.0.0.1:18996:18996', '-v', old+':/oldpkg:ro',
'-v', str(project/'build/linux-amd64')+':/pkg:ro',
'docker.io/library/debian:bookworm-slim', 'sleep', '300')
inside('env', 'BLUEBIRD_ADMIN_PASSWORD='+password, '/oldpkg/install.sh',
'--url', origin, '--listen', '0.0.0.0:18996', '--encrypt', '--no-start')
inside('sed','-i','s/min_free_bytes = 1073741824/min_free_bytes = 0/', '/opt/bluebird/config.toml')
key_before = inside('sha256sum', '/opt/bluebird/var/secrets/age.key')
cookies = {}
def call(method, path, body=None):
data = body if isinstance(body, bytes) else json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(base+'/api/v1'+path, data=data, method=method, headers={
'Origin': origin, 'X-Bluebird-Origin': origin,
'Cookie': '; '.join(k+'='+v for k,v in cookies.items()),
'Content-Type': 'application/octet-stream' if isinstance(body,bytes) else 'application/json'})
with urllib.request.urlopen(req, timeout=10) as response:
for line in response.headers.get_all('Set-Cookie',[]):
parsed = http.cookies.SimpleCookie(); parsed.load(line)
cookies.update({k:v.value for k,v in parsed.items()})
result=response.read()
return result if path.endswith('/download') else json.loads(result)
def start():
subprocess.run(['podman','exec','-d',name,'runuser','-u','bluebird','--','/opt/bluebird/bluebird','serve','--config','/opt/bluebird/config.toml'],check=True)
for _ in range(50):
try:
if call('GET','/health')['ready']: return
except OSError: time.sleep(.1)
raise AssertionError('Container service not ready')
start()
call('GET','/policy')
parcel=call('POST','/uploads',{'files':[{'name':'preserved.txt','size':21}],'days':7,'downloads':5,'renew':True})
call('PUT',f"/uploads/{parcel['id']}/files/{parcel['files'][0]['id']}", b'old encrypted payload')
share=call('POST',f"/uploads/{parcel['id']}/publish",{})
call('POST','/admin/login',{'username':'admin','password':password})
before=inside('sha256sum','/opt/bluebird/config.toml','/opt/bluebird/bluebird')
# Invalid candidates must not stop a running service or change files.
bad=subprocess.run(['podman','exec',name,'/pkg/upgrade.sh','--access-url','http://bad.test/path','--no-start'],capture_output=True)
assert bad.returncode != 0
assert before == inside('sha256sum','/opt/bluebird/config.toml','/opt/bluebird/bluebird')
assert call('GET','/health')['ready']
# No pgrep dependency: /proc/comm identifies only the native service.
inside('sh','-c','for p in /proc/[0-9]*; do [ "$(cat "$p/comm" 2>/dev/null)" != bluebird ] || kill -TERM "${p##*/}"; done')
for _ in range(50):
try: call('GET','/health')
except OSError: break
time.sleep(.1)
# Simulate a new-version integrity-check failure after files were replaced.
inside('sh','-c','mkdir /failpkg; cp /pkg/bluebird /failpkg/bluebird.real; cp /pkg/ARCH /pkg/upgrade.sh /failpkg/')
wrapper='#!/bin/sh\nif [ "$1" = check ]; then exit 1; fi\nexec "$(dirname "$0")/bluebird.real" "$@"\n'
inside('sh','-c','printf "%s" "$1" > /failpkg/bluebird; chmod 755 /failpkg/bluebird','wrapper',wrapper)
failed=subprocess.run(['podman','exec',name,'/failpkg/upgrade.sh','--lan-url',base,'--listen','0.0.0.0:18996','--no-start'],capture_output=True)
assert failed.returncode != 0 and '已恢复旧程序和配置' in failed.stderr.decode()
assert before == inside('sha256sum','/opt/bluebird/config.toml','/opt/bluebird/bluebird')
result=inside('/pkg/upgrade.sh','--lan-url',base,'--listen','0.0.0.0:18996',
'--access-url','http://files.example.test','--no-start')
assert '升级完成' in result
assert key_before == inside('sha256sum','/opt/bluebird/var/secrets/age.key')
assert inside('stat','-c','%U:%G:%a','/opt/bluebird/config.toml').strip() == 'root:bluebird:640'
assert 'data.tar.gz' in inside('find','/opt/bluebird/backups','-type','f')
start();cookies.clear()
call('POST','/admin/login',{'username':'admin','password':password})
assert call('GET','/admin/stats')['objects'] == 1
policy=call('GET','/policy');assert set(policy['access_urls']) == {origin,base,'http://files.example.test'}
for entrance in [origin,base,'http://files.example.test']:
origin=entrance;cookies.clear()
call('POST','/pickup/resolve',{'code':share['code']})
assert call('GET',f"/pickup/files/{parcel['files'][0]['id']}/download") == b'old encrypted payload'
print('升级验证通过:旧管理员密码、加密文件、密钥及取件码保留;三入口下载、权限、备份、非法配置保护及检查失败回滚通过。')
except subprocess.CalledProcessError as error:
print(error.stdout, error.stderr, file=sys.stderr)
raise
finally:
subprocess.run(['podman','rm','-f',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#1677ff"/><link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2"/><link rel="alternate icon" href="/favicon.ico?v=2"/><link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2"/><title>蓝鸟 · 文件快递柜</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
+2222
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"name":"bluebird-web","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"vue-tsc --noEmit && vite build","test:e2e":"playwright test"},"dependencies":{"vue":"^3.5.0","vue-router":"^4.5.0","naive-ui":"^2.43.0","pinia":"^3.0.0","qrcode":"^1.5.4"},"devDependencies":{"@vitejs/plugin-vue":"^6.0.0","typescript":"^5.9.0","vite":"^7.1.0","vue-tsc":"^3.0.0","@types/qrcode":"^1.5.5","@playwright/test":"^1.55.0","@types/node":"^24.0.0"}}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from '@playwright/test'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const root = mkdtempSync(join(tmpdir(), 'bluebird-browser-'))
const config = join(root, 'config.toml')
writeFileSync(config, readFileSync('../deploy/config.example.toml','utf8').replaceAll('127.0.0.1:8080','127.0.0.1:18981').replace('http://localhost:8080','http://127.0.0.1:18981').replace('min_free_bytes = 1073741824','min_free_bytes = 0'))
export default defineConfig({ testDir: './tests', timeout: 30_000, workers: 1, use: { baseURL:'http://127.0.0.1:18981', headless: true, trace: 'retain-on-failure' }, webServer: { command: `BLUEBIRD_ADMIN_PASSWORD=e2e-secret-password ../build/bluebird init --config ${config} && ../build/bluebird serve --config ${config}`, url: 'http://127.0.0.1:18981/api/v1/health', reuseExistingServer:false, timeout:30_000 } })
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">蓝鸟</title>
<rect width="64" height="64" rx="16" fill="#1677ff"/>
<g transform="translate(.875 4) scale(.84)">
<path fill="#fff" d="M14 41c8-1 14-4 18-10 4-7 6-11 13-11 5 0 8 3 9 7l6 3-7 3c-1 8-7 14-16 15-6 1-11-1-15-4l-8 1 6-5-6 1Z"/>
<path fill="#fff" d="M34 34C26 32 23 25 22 18c7 2 13 6 16 11Z"/>
<path fill="#1677ff" d="M25 25c2 5 5 8 10 9l-2 2c-5-2-7-6-8-11Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">蓝鸟</title>
<rect width="64" height="64" rx="16" fill="#1677ff"/>
<g transform="translate(.875 4) scale(.84)">
<path fill="#fff" d="M14 41c8-1 14-4 18-10 4-7 6-11 13-11 5 0 8 3 9 7l6 3-7 3c-1 8-7 14-16 15-6 1-11-1-15-4l-8 1 6-5-6 1Z"/>
<path fill="#fff" d="M34 34C26 32 23 25 22 18c7 2 13 6 16 11Z"/>
<path fill="#1677ff" d="M25 25c2 5 5 8 10 9l-2 2c-5-2-7-6-8-11Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 517 B

+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { NButton,NInput,NInputNumber,NCheckbox,NSelect,NTabs,NTabPane,NTag,useMessage,useDialog } from 'naive-ui'
import { api,post,size,date,states,APIError,type Parcel,type Policy,type Share,type RuntimeSettings,type PageResult,type AuditEntry } from './api'
import ShareDialog from './ShareDialog.vue'
import AdminPager from './AdminPager.vue'
const message=useMessage(),dialog=useDialog()
const authenticated=ref(false),username=ref('admin'),password=ref(''),busy=ref(false),error=ref('')
const stats=ref<Record<string,any>>({}),policy=ref<Policy>(),runtime=ref<RuntimeSettings>(),share=ref<Share|null>(null),activeTab=ref('parcels')
const emptyPage=<T,>(pageSize:number):PageResult<T>=>({items:[],total:0,page:1,page_size:pageSize,pages:1})
const parcels=ref(emptyPage<Parcel>(10)),audit=ref(emptyPage<AuditEntry>(20))
const parcelLoading=ref(false),auditLoading=ref(false),parcelError=ref(''),auditError=ref('')
const parcelScroll=ref<HTMLElement>(),auditScroll=ref<HTMLElement>()
const parcelFilters=reactive({q:'',state:null as string|null})
const auditFilters=reactive({action:null as string|null,source:'',target:'',from:'',to:''})
let appliedParcelFilters={...parcelFilters},appliedAuditFilters={...auditFilters},parcelRequest=0,auditRequest=0
const tabs=[{key:'parcels',label:'文件管理'},{key:'policy',label:'服务策略'},{key:'runtime',label:'容量与保留'},{key:'audit',label:'操作审计'}]
const tabProps=(key:string)=>({role:'tab',id:'tab-'+key,'aria-controls':'panel-'+key,'aria-selected':activeTab.value===key,tabindex:activeTab.value===key?0:-1})
async function tabKey(e:KeyboardEvent){if((e.target as HTMLElement).getAttribute('role')!=='tab')return;let i=tabs.findIndex(t=>t.key===activeTab.value);if(e.key==='ArrowRight')i=(i+1)%tabs.length;else if(e.key==='ArrowLeft')i=(i+tabs.length-1)%tabs.length;else if(e.key==='Home')i=0;else if(e.key==='End')i=tabs.length-1;else return;e.preventDefault();activeTab.value=tabs[i].key;await nextTick();document.getElementById('tab-'+activeTab.value)?.focus()}
const stateOptions=['uploading','ready','revoked','expired','exhausted','restored'].map(value=>({value,label:states[value]}))
const auditNames:Record<string,string>={'admin.login':'管理员登录','admin.login_failed':'管理员登录失败','admin.delete':'删除包裹','admin.file_delete':'删除文件','admin.reissue':'重新发布包裹','admin.cleanup':'清理失效文件','policy.update':'修改服务策略','runtime.update':'修改容量与保留','upload.create':'创建寄件','parcel.publish':'发布寄件','parcel.revoke':'撤销寄件'}
const auditOptions=Object.entries(auditNames).map(([value,label])=>({value,label:label+' · '+value}))
const numericFields:{key:keyof Policy;label:string;unit?:string}[]=[{key:'max_file_bytes',label:'单文件大小',unit:'bytes'},{key:'max_batch_bytes',label:'单次总大小',unit:'bytes'},{key:'max_files',label:'单次文件数'},{key:'default_days',label:'默认保留天数'},{key:'max_initial_days',label:'初始保留上限(天)'},{key:'max_lifetime_days',label:'总保留期上限(天)'},{key:'default_downloads',label:'默认下载次数'},{key:'max_downloads',label:'最多下载次数'},{key:'renewal_days',label:'每次续期天数(0关闭)'},{key:'ip_daily_bytes',label:'单 IP 日上传量',unit:'bytes'},{key:'ip_daily_uploads',label:'单 IP 日寄件次数'},{key:'session_daily_uploads',label:'匿名会话日寄件次数'}]
const runtimeFields:{key:keyof RuntimeSettings;label:string;bytes?:boolean;min:number}[]=[{key:'file_budget',label:'文件存储预算(MiB)',bytes:true,min:1},{key:'data_budget',label:'服务数据总预算(MiB)',bytes:true,min:65},{key:'min_free_bytes',label:'卷最少保留空间(MiB)',bytes:true,min:0},{key:'audit_retention_days',label:'审计保留天数',min:1},{key:'audit_max_records',label:'最多审计条数',min:1},{key:'audit_max_bytes',label:'审计内容预算(MiB)',bytes:true,min:1}]
function numeric(key:keyof Policy){const n=Number(policy.value?.[key]??0);return ['max_file_bytes','max_batch_bytes','ip_daily_bytes'].includes(key)?n/1048576:n}
function update(key:keyof Policy,n:number|null){if(!policy.value||n===null)return;(policy.value as unknown as Record<string,unknown>)[key]=['max_file_bytes','max_batch_bytes','ip_daily_bytes'].includes(key)?Math.round(n*1048576):n}
function runtimeNumber(key:keyof RuntimeSettings,bytes=false){const n=runtime.value?.[key]??0;return bytes?n/1048576:n}
function runtimeUpdate(key:keyof RuntimeSettings,n:number|null,bytes=false){if(runtime.value&&n!==null)runtime.value[key]=bytes?Math.round(n*1048576):n}
const fileUsage=computed(()=>(stats.value.object_bytes||0)+(stats.value.reserved_bytes||0))
const occupancy=computed(()=>stats.value.file_budget?Math.min(100,fileUsage.value/stats.value.file_budget*100):0)
function failed(e:unknown){if(e instanceof APIError&&e.status===401)authenticated.value=false;return (e as Error).message}
async function loadStats(){stats.value=await api('/admin/stats')}
async function loadParcels(page=parcels.value.page,pageSize=parcels.value.page_size){
const request=++parcelRequest;parcelLoading.value=true;parcelError.value=''
const q=new URLSearchParams({page:String(page),page_size:String(pageSize)})
if(appliedParcelFilters.q.trim())q.set('q',appliedParcelFilters.q.trim())
if(appliedParcelFilters.state)q.set('state',appliedParcelFilters.state)
try{const result=await api<PageResult<Parcel>>('/admin/parcels?'+q);if(request===parcelRequest){parcels.value=result;await nextTick();parcelScroll.value?.scrollTo(0,0)}}catch(e){if(request===parcelRequest)parcelError.value=failed(e)}finally{if(request===parcelRequest)parcelLoading.value=false}
}
async function loadAudit(page=audit.value.page,pageSize=audit.value.page_size){
const request=++auditRequest;auditLoading.value=true;auditError.value=''
const q=new URLSearchParams({page:String(page),page_size:String(pageSize)})
for(const key of ['action','source','target'] as const){const value=appliedAuditFilters[key]?.trim();if(value)q.set(key,value)}
for(const key of ['from','to'] as const){if(appliedAuditFilters[key])q.set(key,String(Math.floor(new Date(appliedAuditFilters[key]).getTime()/1000)))}
try{const result=await api<PageResult<AuditEntry>>('/admin/audit?'+q);if(request===auditRequest){audit.value=result;await nextTick();auditScroll.value?.scrollTo(0,0)}}catch(e){if(request===auditRequest)auditError.value=failed(e)}finally{if(request===auditRequest)auditLoading.value=false}
}
function searchParcels(){appliedParcelFilters={...parcelFilters};void loadParcels(1)}
function resetParcels(){Object.assign(parcelFilters,{q:'',state:null});searchParcels()}
function searchAudit(){if(auditFilters.from&&auditFilters.to&&auditFilters.from>auditFilters.to){auditError.value='开始时间不能晚于结束时间';return}appliedAuditFilters={...auditFilters};void loadAudit(1)}
function resetAudit(){Object.assign(auditFilters,{action:null,source:'',target:'',from:'',to:''});searchAudit()}
async function refreshLists(){try{await Promise.all([loadStats(),loadParcels(),loadAudit()]);error.value=''}catch(e){error.value=failed(e)}}
async function load(){try{const [c,r]=await Promise.all([api<Policy>('/admin/policy'),api<RuntimeSettings>('/admin/runtime'),refreshLists()]);policy.value=c;runtime.value=r}catch(e){error.value=failed(e)}}
async function login(){busy.value=true;error.value='';try{await post('/admin/login',{username:username.value,password:password.value});password.value='';authenticated.value=true;await load()}catch(e){error.value=failed(e)}finally{busy.value=false}}
async function logout(){await post('/admin/logout');authenticated.value=false}
async function save(){try{policy.value=await api('/admin/policy',{method:'PUT',body:JSON.stringify(policy.value)});message.success('策略已保存,新上传会采用新策略');await refreshLists()}catch(e){message.error(failed(e))}}
function saveRuntime(){dialog.warning({title:'保存容量与审计设置?',content:'容量降低不会删除有效文件,超出预算时暂停新上传。缩短审计天数、条数或内容预算会立即清理最旧记录。',positiveText:'保存设置',negativeText:'取消',onPositiveClick:async()=>{try{runtime.value=await api('/admin/runtime',{method:'PUT',body:JSON.stringify(runtime.value)});await refreshLists();message.success('容量与审计设置已生效')}catch(e){message.error(failed(e))}}})}
function remove(p:Parcel,fileId?:string){dialog.warning({title:fileId?'删除这个文件?':'删除这份包裹?',content:'将立即禁止新下载。共享对象只有在所有引用释放后才回收。',positiveText:'删除',negativeText:'取消',onPositiveClick:async()=>{try{await post(fileId?`/admin/files/${fileId}/delete`:`/admin/parcels/${p.id}/delete`);await refreshLists()}catch(e){message.error(failed(e))}}})}
function reissue(p:Parcel){dialog.warning({title:'重新发布恢复的包裹?',content:'将按当前默认期限和下载次数创建新的分享授权,所有旧取件码保持失效。',positiveText:'重新发布',negativeText:'取消',onPositiveClick:async()=>{try{share.value=await post(`/admin/parcels/${p.id}/reissue`);await refreshLists()}catch(e){message.error(failed(e))}}})}
async function cleanup(){try{await post('/admin/cleanup');message.success('清理完成');await refreshLists()}catch(e){message.error(failed(e))}}
onMounted(async()=>{try{authenticated.value=(await api<{authenticated:boolean}>('/admin/status')).authenticated;if(authenticated.value)await load()}catch(e){error.value=failed(e)}})
</script>
<template>
<section class="intro compact"><span class="eyebrow">蓝鸟 · 管理</span><h1>管理控制台。</h1><p>管理服务开关、文件、容量与保留规则。寄件和取件通过公共页面匿名使用。</p></section>
<div v-if="error" class="notice error" role="alert">{{error}}</div>
<section class="card login-card" v-if="!authenticated"><h2>管理员登录</h2><label class="field-label">账号</label><n-input v-model:value="username" autocomplete="username"/><label class="field-label">密码</label><n-input v-model:value="password" type="password" autocomplete="current-password" @keyup.enter="login"/><n-button type="primary" block :loading="busy" @click="login">登录</n-button></section>
<template v-else>
<div class="admin-toolbar"><span class="muted">当前传输:上传 {{stats.active_uploads||0}} · 下载 {{stats.active_downloads||0}}</span><n-button @click="load">刷新</n-button><n-button @click="cleanup">清理失效文件</n-button><n-button text @click="logout">退出登录</n-button></div>
<div class="stats-grid">
<div class="card stat"><small>文件实体占用</small><strong>{{size(stats.object_bytes||0)}}</strong><span>{{stats.objects||0}} 个对象 · {{stats.files||0}} 份文件记录</span></div>
<div class="card stat"><small>上传预留</small><strong>{{size(stats.reserved_bytes||0)}}</strong><span>已申请、尚未完成的文件空间</span></div>
<div class="card stat"><small>分享逻辑大小</small><strong>{{size(stats.logical_bytes||0)}}</strong><span>相同内容在各分享中分别显示</span></div>
<div class="card stat"><small>存储卷可用空间</small><strong>{{size(stats.disk_free_bytes||0)}}</strong><span>卷总容量 {{size(stats.disk_total_bytes||0)}},含其他程序占用</span></div>
</div>
<section class="card admin-management">
<div class="capacity-label"><span>文件使用 / 预算:{{size(fileUsage)}} / {{size(stats.file_budget||0)}}</span><span>服务数据总预算 {{size(stats.data_budget||0)}}</span></div>
<div class="capacity-track"><div :style="{width:occupancy+'%'}"></div></div>
<div class="capacity-details"><span>文件使用 = 实体文件占用 + 上传预留</span><span>数据库、WAL及隔离对象 {{size(stats.metadata_bytes||0)}}</span></div>
<p v-if="fileUsage>stats.file_budget" class="notice capacity-warning">现有使用超过新预算,暂不接收额外文件;已有文件仍按原规则保留。</p>
<n-tabs v-model:value="activeTab" type="line" @keydown="tabKey">
<n-tab-pane name="parcels" id="panel-parcels" role="tabpanel" :tab-props="tabProps('parcels')" tab="文件管理">
<div class="admin-panel">
<form class="admin-filters parcel-filters" @submit.prevent="searchParcels">
<div><label class="field-label" for="parcel-query">文件名或包裹 ID</label><n-input v-model:value="parcelFilters.q" :input-props="{id:'parcel-query'}" placeholder="支持部分名称或 ID" :maxlength="80" clearable/></div>
<div><label class="field-label">包裹状态</label><n-select v-model:value="parcelFilters.state" :options="stateOptions" placeholder="全部状态" clearable/></div>
<div class="filter-actions"><n-button type="primary" attr-type="submit" :loading="parcelLoading">搜索</n-button><n-button :disabled="parcelLoading" @click="resetParcels">重置</n-button></div>
</form>
<div v-if="parcelError" class="notice error" role="alert">{{parcelError}}</div>
<div ref="parcelScroll" class="admin-scroll" :aria-busy="parcelLoading">
<p v-if="!parcels.items.length" class="empty">{{parcelLoading?'正在加载…':'没有符合条件的包裹。'}}</p>
<div v-for="p in parcels.items" :key="p.id" class="admin-parcel">
<div class="card-heading"><div><n-tag>{{states[p.state]||p.state}}</n-tag><strong>{{p.count}} 个文件 · {{size(p.size)}}</strong><small>{{date(p.created)}} · 来源 {{p.ip}}</small></div><div class="actions"><n-button v-if="p.state==='restored'" @click="reissue(p)">重新发布</n-button><n-button v-if="p.state!=='revoked'" size="small" type="error" secondary @click="remove(p)">删除包裹</n-button></div></div>
<div class="admin-file" v-for="f in p.files" :key="f.id"><span :title="f.name">{{f.name}}</span><small>{{size(f.size)}} · {{states[f.status]}} · 剩余 {{f.remaining}} 次 · {{date(f.expires)}}</small><n-button v-if="f.status!=='deleted'&&f.status!=='deleting'" text type="error" size="small" @click="remove(p,f.id)">删除文件</n-button></div>
<code>{{p.id}}</code>
</div>
</div>
<admin-pager label="文件管理分页" unit="个包裹" :page="parcels.page" :page-size="parcels.page_size" :total="parcels.total" :pages="parcels.pages" :loading="parcelLoading" :sizes="[10,20,50]" @page="n=>loadParcels(n)" @size="n=>loadParcels(1,n)"/>
</div>
</n-tab-pane>
<n-tab-pane name="policy" id="panel-policy" role="tabpanel" :tab-props="tabProps('policy')" tab="服务策略">
<div class="admin-panel" v-if="policy"><div class="admin-scroll">
<div class="policy-switches"><n-checkbox v-model:checked="policy.anonymous_upload">开放寄件服务</n-checkbox><n-checkbox v-model:checked="policy.anonymous_download">开放取件服务</n-checkbox><n-checkbox v-model:checked="policy.require_password">强制取件密码</n-checkbox></div>
<p class="settings-explanation">寄件、取件均无需账号。取消勾选会暂停对应的公共服务,不会要求用户注册;管理员登录也不绕过这些开关。</p>
<div class="policy-grid"><div v-for="field in numericFields" :key="field.key"><label class="field-label">{{field.label}}{{field.unit?'(MiB)':''}}</label><n-input-number :value="numeric(field.key)" :min="field.key==='renewal_days'?0:1" @update:value="n=>update(field.key,n)"/></div><div><label class="field-label">续期规则</label><n-select v-model:value="policy.renewal_mode" :options="[{label:'当前到期时间加续期天数',value:'add'},{label:'至少保留至下载后的指定天数',value:'sliding'}]"/></div></div>
<div class="notice">新上传采用策略快照。服务开关立即生效;降低期限不改写已有分享。</div>
</div><div class="admin-panel-footer"><n-button type="primary" @click="save">保存策略</n-button></div></div>
</n-tab-pane>
<n-tab-pane name="runtime" id="panel-runtime" role="tabpanel" :tab-props="tabProps('runtime')" tab="容量与保留">
<div class="admin-panel" v-if="runtime"><div class="admin-scroll">
<div class="policy-grid runtime-grid"><div v-for="field in runtimeFields" :key="field.key"><label class="field-label">{{field.label}}</label><n-input-number :value="runtimeNumber(field.key,field.bytes)" :min="field.min" @update:value="n=>runtimeUpdate(field.key,n,field.bytes)"/></div></div>
<p class="settings-explanation">文件预算限制本服务的实体文件与上传预留,数据总预算另计数据库等维护空间,须至少多留64MiB。保存后立即生效;降低预算不会删掉有效文件。存储卷可用空间属于整个卷,无法按本服务独占计算。</p>
<p class="settings-explanation">审计默认保留30天,最多10万条、32MiB内容预算;任一上限先到时清理最旧记录。写入时检查容量,每分钟清理过期记录。内容预算按UTF-8字段和每条开销估算;删除后SQLite复用空闲页,数据库文件不会立即缩小。严格目录占用上限仍需主机文件系统配额。</p>
</div><div class="admin-panel-footer"><n-button type="primary" @click="saveRuntime">保存容量与审计设置</n-button></div></div>
</n-tab-pane>
<n-tab-pane name="audit" id="panel-audit" role="tabpanel" :tab-props="tabProps('audit')" tab="操作审计">
<div class="admin-panel">
<form class="admin-filters audit-filters" @submit.prevent="searchAudit">
<div><label class="field-label" for="audit-from">开始时间</label><input id="audit-from" v-model="auditFilters.from" class="filter-date" type="datetime-local"/></div>
<div><label class="field-label" for="audit-to">结束时间</label><input id="audit-to" v-model="auditFilters.to" class="filter-date" type="datetime-local"/></div>
<div><label class="field-label">操作类型</label><n-select v-model:value="auditFilters.action" :options="auditOptions" placeholder="全部操作" clearable filterable tag/></div>
<div><label class="field-label" for="audit-source">来源 IP</label><n-input v-model:value="auditFilters.source" :input-props="{id:'audit-source'}" placeholder="IP 或地址片段" :maxlength="100" clearable/></div>
<div class="filter-target"><label class="field-label" for="audit-target">目标标识</label><n-input v-model:value="auditFilters.target" :input-props="{id:'audit-target'}" placeholder="包裹或文件 ID,支持片段" :maxlength="80" clearable/></div>
<div class="filter-actions"><n-button type="primary" attr-type="submit" :loading="auditLoading">搜索</n-button><n-button :disabled="auditLoading" @click="resetAudit">重置</n-button><span class="muted">时间按当前设备时区</span></div>
</form>
<p class="audit-summary">已存储 {{stats.audit_records||0}} 条 · 内容估算 {{size(stats.audit_content_bytes||0)}}。保留规则在“容量与保留”设置。</p>
<div v-if="auditError" class="notice error" role="alert">{{auditError}}</div>
<div ref="auditScroll" class="admin-scroll audit-scroll" :aria-busy="auditLoading">
<table class="audit-table"><thead><tr><th>时间</th><th>操作</th><th>目标标识</th><th>来源 IP</th></tr></thead><tbody>
<tr v-for="entry in audit.items" :key="entry.id"><td data-label="时间"><time>{{date(entry.created)}}</time></td><td data-label="操作"><strong>{{auditNames[entry.action]||entry.action}}</strong><small>{{entry.action}}</small></td><td data-label="目标标识"><code>{{entry.target||'—'}}</code></td><td data-label="来源 IP"><span>{{entry.source||'—'}}</span></td></tr>
</tbody></table>
<p v-if="!audit.items.length" class="empty">{{auditLoading?'正在加载…':'没有符合条件的审计记录。'}}</p>
</div>
<admin-pager label="操作审计分页" unit="条" :page="audit.page" :page-size="audit.page_size" :total="audit.total" :pages="audit.pages" :loading="auditLoading" :sizes="[20,50,100]" @page="n=>loadAudit(n)" @size="n=>loadAudit(1,n)"/>
</div>
</n-tab-pane>
</n-tabs>
</section>
</template>
<share-dialog :share="share" @close="share=null"/>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { NButton } from 'naive-ui'
const props = defineProps<{ page:number; pageSize:number; total:number; pages:number; loading:boolean; unit:string; label:string; sizes:number[] }>()
const emit = defineEmits<{ page:[number]; size:[number] }>()
const jump = ref(props.page)
watch(() => props.page, n => jump.value = n)
function go() { if (Number.isInteger(jump.value)) emit('page', Math.max(1, Math.min(props.pages, jump.value))) }
</script>
<template>
<nav class="admin-pagination" :aria-label="label">
<span class="pagination-total">共 {{total}} {{unit}}</span>
<label class="page-size">每页 <select :value="pageSize" :disabled="loading" :aria-label="label+'每页条数'" @change="emit('size',Number(($event.target as HTMLSelectElement).value))"><option v-for="n in sizes" :key="n" :value="n">{{n}}</option></select> {{unit}}</label>
<div class="actions page-buttons"><n-button size="small" :disabled="loading||page<=1" @click="emit('page',page-1)">上一页</n-button><span aria-live="polite">第 {{page}} / {{pages}} 页</span><n-button size="small" :disabled="loading||page>=pages" @click="emit('page',page+1)">下一页</n-button></div>
<form class="page-jump" @submit.prevent="go"><label>跳至 <input v-model.number="jump" type="number" min="1" :max="pages" :disabled="loading||pages===1" :aria-label="label+'跳转页码'"/> 页</label><n-button size="small" attr-type="submit" :disabled="loading||pages===1">跳转</n-button></form>
</nav>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { computed, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'
const route = useRoute()
const admin = computed(() => route.path === '/admin')
watchEffect(() => { document.title = admin.value ? '管理控制台 · 蓝鸟' : '蓝鸟 · 文件快递柜' })
const theme = { common: { primaryColor:'#1677ff',primaryColorHover:'#4096ff',primaryColorPressed:'#0958d9',borderRadius:'8px',fontFamily:'Inter, "PingFang SC", "Microsoft YaHei", sans-serif' } }
</script>
<template><n-config-provider :theme-overrides="theme"><n-message-provider><n-dialog-provider><div class="site" :class="{'admin-site':admin}"><header class="header"><component :is="admin?'div':'router-link'" :to="admin?undefined:'/'" class="brand"><img src="/logo.svg?v=2" class="brand-mark" alt="蓝鸟标志" width="42" height="42"/><span>蓝鸟<small>{{admin?'管理控制台':'文件快递柜'}}</small></span></component><nav v-if="!admin" aria-label="主导航"><router-link to="/">寄文件</router-link><router-link to="/pickup">取文件</router-link><router-link to="/mine">我的寄件</router-link></nav><span v-else class="admin-badge">管理控制台</span></header><main><router-view /></main><footer><span>{{admin?'蓝鸟 · 管理控制台':'文件随手寄,凭码安心取。'}}</span><span>{{admin?'系统与存储管理':'匿名分享 · 自主存储'}}</span></footer></div></n-dialog-provider></n-message-provider></n-config-provider></template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref,onMounted } from 'vue'
import { NButton,NTag,useMessage,useDialog } from 'naive-ui'
import { api,post,size,date,states,APIError,type Parcel,type Share,type PublicPolicy,type FileRecord } from './api'
import ShareDialog from './ShareDialog.vue'
const receiptInput=ref<HTMLInputElement>();const message=useMessage();const dialog=useDialog();const parcels=ref<Parcel[]>([]);const error=ref('');const share=ref<Share|null>(null);const visitorExpires=ref(0)
const token=(p:Parcel)=>sessionStorage.getItem('manage:'+p.id)||undefined
async function load(){try{error.value='';visitorExpires.value=(await api<PublicPolicy>('/policy')).visitor_expires;parcels.value=await api('/mine');const keys=Array.from({length:sessionStorage.length},(_,i)=>sessionStorage.key(i)!).filter(k=>k.startsWith('manage:'));for(const key of keys){if(!parcels.value.some(p=>p.id===key.slice(7))){try{parcels.value.unshift(await fetchReceipt(key.slice(7),sessionStorage.getItem(key)!))}catch{sessionStorage.removeItem(key)}}}}catch(e){error.value=(e as Error).message}}
async function code(p:Parcel){try{share.value=await post(`/parcels/${p.id}/code`,{},token(p));message.info('新取件码已生成,旧码已失效')}catch(e){message.error((e as Error).message)}}
function revoke(p:Parcel){dialog.warning({title:'撤销这份寄件?',content:'撤销后不再允许新下载,正在传输的请求可以结束。',positiveText:'撤销',negativeText:'保留',onPositiveClick:async()=>{try{await post(`/parcels/${p.id}/revoke`,{},token(p));await load()}catch(e){message.error((e as Error).message)}}})}
function exportReceipt(p:Parcel){const t=token(p);if(!t){message.info('原始管理凭据未保存在当前标签页。仍可凭有效浏览器身份管理寄件。');return};const blob=new Blob([JSON.stringify({parcel:p.id,manage_token:t},null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=`蓝鸟-${p.id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
async function fetchReceipt(id:string,t:string){try{const p=await api<{id:string;state:string;created:number;expires:number;files:FileRecord[]}>(`/uploads/${id}`,{headers:{Authorization:`Bearer ${t}`}});const available=p.files.some(f=>f.available);return {...p,state:p.state==='ready'&&!available?'exhausted':p.state,count:p.files.length,size:p.files.reduce((s,f)=>s+f.size,0)}}catch(e){if(e instanceof APIError&&e.status===404)throw new Error('管理凭据无效或寄件记录已清理,无法恢复文件');throw e}}
async function importReceipt(event:Event){const input=event.target as HTMLInputElement;const file=input.files?.[0];input.value='';if(!file)return;try{if(file.size>2048)throw new Error('管理凭据文件过大');const r=JSON.parse(await file.text());if(!/^[a-f0-9]{32}$/.test(r.parcel)||!/^[A-Za-z0-9_-]{43}$/.test(r.manage_token))throw new Error('管理凭据格式不正确');const p=await fetchReceipt(r.parcel,r.manage_token);sessionStorage.setItem('manage:'+p.id,r.manage_token);await load();if(!['ready','uploading'].includes(p.state))message.warning('已找到历史寄件记录,但文件已失效或被删除;凭据不能恢复文件');else message.success('管理凭据已导入,仅保存在当前标签页')}catch(e){message.error((e as Error).message)}}
onMounted(load)
</script>
<template><section class="intro compact"><span class="eyebrow">蓝鸟 · 我的寄件</span><h1>我的寄件。</h1><p>查看当前浏览器中的寄件记录,或导入已保存的管理凭据。</p></section><div v-if="error" class="notice error" role="alert">{{error}}</div><section class="card"><div class="card-heading"><h2>最近寄件</h2><div class="actions"><input ref="receiptInput" type="file" accept="application/json,.json" hidden @change="importReceipt"/><n-button @click="receiptInput?.click()">导入管理凭据</n-button><n-button text @click="load">刷新</n-button></div></div><details class="receipt-info"><summary>管理凭据有什么用?</summary><p>管理凭据用于证明某份寄件属于你。导出并保存 JSON 凭据文件后,可以在另一台设备或浏览器导入,用于撤销寄件或重新生成取件码。</p><p>凭据包含该寄件的管理权限,请妥善保管,不要随取件码一起转发给收件人。</p><p>凭据不是文件备份,不会延长保留时间。文件到期、次数用尽或被删除后,导入凭据不能恢复文件;寄件记录被清理后,凭据失效。</p></details><p v-if="!parcels.length" class="empty">这里还没有寄件。<router-link to="/">去上传一份文件</router-link></p><article class="parcel-row" v-for="p in parcels" :key="p.id"><div><n-tag :type="p.state==='ready'?'success':'default'">{{states[p.state]||p.state}}</n-tag><strong>{{p.count}} 个文件 · {{size(p.size)}}</strong><small>{{date(p.created)}} 寄出 · 到期 {{date(p.expires)}}</small><code>{{p.id.slice(0,12)}}</code><p class="parcel-note" v-if="!['ready','uploading'].includes(p.state)">历史记录仅供查看,管理凭据不能恢复已失效或删除的文件。</p></div><div class="actions"><n-button v-if="p.state==='ready'" @click="code(p)">重新生成取件码</n-button><n-button v-if="p.state==='ready'||p.state==='uploading'" type="error" secondary @click="revoke(p)">撤销</n-button><n-button v-if="['ready','uploading'].includes(p.state)" text :disabled="!token(p)" @click="exportReceipt(p)">导出管理凭据</n-button></div></article><p class="muted" v-if="visitorExpires">此浏览器身份有效至 {{date(visitorExpires)}}。凭据只管理对应寄件,文件保留期另行计算。</p></section><share-dialog :share="share" @close="share=null"/></template>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { NInput, NButton } from 'naive-ui'
import { api, post, APIError, size, date, type FileRecord } from './api'
const route=useRoute();const code=ref(String(route.params.code ?? ''));const password=ref('');const needsPassword=ref(false);const busy=ref(false);const error=ref('');const parcel=ref<{id:string;files:FileRecord[];renewal_days:number;renewal_mode:string}|null>(null)
async function resolve(){busy.value=true;error.value='';try{parcel.value=await post('/pickup/resolve',{code:code.value,password:password.value});needsPassword.value=false}catch(e){if(e instanceof APIError&&e.body.password_required)needsPassword.value=true;error.value=(e as Error).message}finally{busy.value=false}}
async function refresh(){try{parcel.value=await api('/pickup')}catch(e){error.value=(e as Error).message;parcel.value=null}}
function download(file:FileRecord){const a=document.createElement('a');a.href=`/api/v1/pickup/files/${file.id}/download`;a.download=file.name;a.click();setTimeout(refresh,1500)}
onMounted(()=>{if(code.value)resolve()});watch(()=>route.params.code,v=>{code.value=String(v??'');parcel.value=null;password.value='';error.value='';needsPassword.value=false;if(code.value)resolve()})
</script>
<template><section class="intro compact"><span class="eyebrow">蓝鸟 · 取件</span><h1>凭码取件。</h1><p>输入取件码,即可收取对方分享的文件。</p></section><section class="card pickup-card"><template v-if="!parcel"><label class="field-label">取件码</label><n-input v-model:value="code" size="large" placeholder="例如 7K3M-P9TX-2R" :maxlength="20" @keyup.enter="resolve"/><div v-if="needsPassword" class="password-field"><label class="field-label">取件密码</label><n-input v-model:value="password" type="password" size="large" placeholder="输入上传者设置的密码" :maxlength="128" @keyup.enter="resolve"/></div><div v-if="error" class="notice error" role="alert">{{error}}</div><n-button type="primary" size="large" block :loading="busy" :disabled="!code" @click="resolve">查找文件</n-button><p class="muted">扫码后会自动填入取件码。不会自动下载文件。</p></template><template v-else><div class="card-heading"><h2>你的文件已就位</h2><n-button text @click="parcel=null;password='';code=''">换一个取件码</n-button></div><div v-if="error" class="notice error">{{error}}</div><div class="file-row pickup-file" v-for="f in parcel.files" :key="f.id"><span class="file-icon">↓</span><div class="file-info"><strong>{{f.name}}</strong><small>{{size(f.size)}} · 剩余 {{f.remaining}} 次</small><small>到期 {{date(f.expires)}}</small></div><n-button type="primary" secondary :disabled="!f.available" @click="download(f)">{{f.available?'下载':'已失效'}}</n-button></div><div class="notice" v-if="parcel.renewal_days">成功传输后,{{parcel.renewal_mode==='add'?`本文件延长 ${parcel.renewal_days} 天`:`本文件至少保留到下载后 ${parcel.renewal_days} 天`}},仍受总保留期上限约束。</div><p class="muted">开始下载即计一次;中途断开不会返还次数,也不会续期。授权 10 分钟有效。</p><n-button text @click="refresh">刷新状态</n-button></template></section></template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { NButton, NInput, NInputNumber, NCheckbox, NProgress, useMessage } from 'naive-ui'
import { api, post, upload, size, type PublicPolicy, type Share } from './api'
import ShareDialog from './ShareDialog.vue'
const message = useMessage(); const config = ref<PublicPolicy>(); const error = ref(''); const files = ref<File[]>([]); const days = ref(1); const downloads = ref(5); const password = ref(''); const renew = ref(true); const input = ref<HTMLInputElement>(); const busy = ref(false); const percentages = ref<number[]>([]); const session = ref<{id:string;manage_token:string;files:{id:string}[]} | null>(null); const share = ref<Share|null>(null); const published = ref(false); const dragging = ref(false); let controller: AbortController | null = null
const total = computed(() => files.value.reduce((n,f) => n+f.size,0)); const policy = computed(() => config.value?.policy)
onMounted(async () => { try { config.value = await api<PublicPolicy>('/policy'); days.value = config.value.policy.default_days; downloads.value = config.value.policy.default_downloads } catch(e) { error.value = (e as Error).message } })
onBeforeUnmount(() => controller?.abort())
function select(list: FileList | null) { if (!list || busy.value || session.value) return; const next = [...files.value,...Array.from(list)]; if (!policy.value) return; if (next.length > policy.value.max_files || next.some(f => f.size > policy.value!.max_file_bytes) || next.reduce((n,f)=>n+f.size,0)>policy.value.max_batch_bytes) { message.error('文件数量或大小超过当前限制'); return } files.value=next; if (input.value) input.value.value='' }
function drop(event: DragEvent) { dragging.value=false; select(event.dataTransfer?.files ?? null) }
async function send() {
if (!files.value.length || !policy.value) return; if ((password.value || policy.value.require_password) && new TextEncoder().encode(password.value).length<8) { message.warning('取件密码至少8字节'); return }
busy.value=true; error.value=''; controller = new AbortController()
try { if (!session.value) { session.value=await post<{id:string;manage_token:string;files:{id:string}[]}>('/uploads',{files:files.value.map(f=>({name:f.name,size:f.size})),days:days.value,downloads:downloads.value,password:password.value,renew:renew.value}); percentages.value=files.value.map(()=>0); sessionStorage.setItem('manage:'+session.value!.id,session.value!.manage_token) }
const work = files.value.map((_,i)=>i).filter(i=>percentages.value[i]!==100); let cursor=0
const worker = async () => { while(cursor<work.length) { const i=work[cursor++]; await upload(`/uploads/${session.value!.id}/files/${session.value!.files[i].id}`,files.value[i],session.value!.manage_token,n=>{percentages.value[i]=Math.min(n,99)},controller!.signal); percentages.value[i]=100 } }
const results=await Promise.allSettled([worker().catch(e=>{controller!.abort();throw e}),worker().catch(e=>{controller!.abort();throw e})]);const failed=results.find(r=>r.status==='rejected');if(failed?.status==='rejected')throw failed.reason; share.value=await post<Share>(`/uploads/${session.value!.id}/publish`,{},session.value!.manage_token); published.value=true; message.success('上传完成,已生成取件码')
} catch(e) { controller.abort(); error.value=(e as Error).message; if (session.value) { try { const status=await api<{state:string;files:{status:string}[]}>(`/uploads/${session.value!.id}`,{headers:{Authorization:`Bearer ${session.value!.manage_token}`}}); percentages.value=status.files.map(f=>f.status==='ready'?100:0); if(status.state==='ready') { share.value=await post<Share>(`/parcels/${session.value!.id}/code`,{},session.value!.manage_token);published.value=true } } catch{} } } finally { busy.value=false }
}
async function cancel() { controller?.abort(); if(session.value) { try { await post(`/parcels/${session.value!.id}/revoke`,{},session.value!.manage_token); session.value=null; files.value=[]; percentages.value=[]; error.value='' }catch(e){error.value=(e as Error).message} } }
function reset() { files.value=[]; session.value=null; percentages.value=[]; published.value=false; password.value='';error.value='' }
</script>
<template><section class="intro"><span class="eyebrow">蓝鸟 · 寄件</span><h1>寄一份文件,<br/><span>分享少一点麻烦。</span></h1><p>无需注册。上传后获取取件码,电脑与手机都能轻松收取。</p></section><div v-if="error" class="notice error" role="alert">{{error}}</div><div class="send-layout" v-if="policy"><section class="card upload-card"><div class="card-heading"><h2>准备寄件</h2><span>{{files.length}} / {{policy.max_files}} 个文件</span></div><input ref="input" type="file" multiple hidden @change="select(($event.target as HTMLInputElement).files)"/><div class="dropzone" :class="{dragging}" role="button" tabindex="0" aria-label="选择上传文件" @click="!session && input?.click()" @keydown.enter="!session && input?.click()" @dragover.prevent="dragging=true" @dragleave.prevent="dragging=false" @drop.prevent="drop"><span class="upload-glyph">↑</span><h3>{{files.length?'还可以继续添加文件':'将文件拖到这里'}}</h3><p>或点击选择文件</p><small>单文件 ≤ {{size(policy.max_file_bytes)}} · 一次最多 {{policy.max_files}} 个</small></div><div class="file-list" v-if="files.length"><div class="file-row" v-for="(file,i) in files" :key="i"><span class="file-icon">↗</span><div class="file-info"><strong>{{file.name}}</strong><small>{{size(file.size)}}<span v-if="percentages[i]===100"> · 已完成</span></small><n-progress v-if="busy || percentages[i]" type="line" :percentage="percentages[i] || 0" :show-indicator="false" /></div><n-button v-if="!session" quaternary size="small" aria-label="移除文件" @click="files.splice(i,1)">×</n-button></div></div><div class="upload-bottom"><span>总计 {{size(total)}} / {{size(policy.max_batch_bytes)}}</span><n-button v-if="published" type="primary" @click="reset">再寄一份</n-button><n-button v-else type="primary" :loading="busy" :disabled="!files.length || !policy.anonymous_upload" @click="send">{{session?'继续上传':'上传并生成取件码'}}</n-button></div><n-button v-if="session&&!published" text @click="cancel">取消本次寄件</n-button><p v-if="!policy.anonymous_upload" class="notice">管理员暂时关闭了上传。</p></section><aside class="card settings-card"><span class="eyebrow">取件规则</span><h2>设置取件规则</h2><label>初始保留时间<span>最多 {{policy.max_initial_days}} 天</span></label><n-input-number v-model:value="days" :min="1" :max="policy.max_initial_days" :disabled="!!session"><template #suffix>天</template></n-input-number><label>每个文件的下载次数<span>最多 {{policy.max_downloads}} 次</span></label><n-input-number v-model:value="downloads" :min="1" :max="policy.max_downloads" :disabled="!!session"><template #suffix>次</template></n-input-number><label>取件密码<span>{{policy.require_password?'必填':'可选'}}</span></label><n-input v-model:value="password" type="password" show-password-on="click" placeholder="至少 8 字节" :maxlength="128" :disabled="!!session"/><div class="renew-option"><n-checkbox v-model:checked="renew" :disabled="!!session || !policy.renewal_days">下载后自动续期</n-checkbox><p v-if="policy.renewal_days">{{policy.renewal_mode==='add'?`每次成功下载延长 ${policy.renewal_days} 天`:`至少保留至下载后的 ${policy.renewal_days} 天`}},最长总保留 {{policy.max_lifetime_days}} 天。次数不会重置。</p><p v-else>管理员已关闭自动续期。</p></div><div class="privacy-note"><strong>分享码就是取件钥匙</strong><p>重要文件请设置密码,并通过不同渠道发送。上传后可在“我的寄件”中提前撤销。</p></div></aside></div><share-dialog :share="share" @close="share=null"/></template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { NModal, NButton, NSelect, useMessage } from 'naive-ui'
import QRCode from 'qrcode'
import { copyText } from './clipboard'
import { date, type Share } from './api'
const props = defineProps<{ share: Share | null }>()
const emit = defineEmits<{ close: [] }>()
const dialogContent = ref<HTMLElement>(); const shareURL=ref(''); const qr = ref(''); let qrRequest=0; const message = useMessage()
watch(() => props.share, v => {shareURL.value=v?.url||''}, {immediate:true})
watch(shareURL, async v => {const request=++qrRequest;const value=v?await QRCode.toDataURL(v,{width:232,margin:1,errorCorrectionLevel:'M'}):'';if(request===qrRequest)qr.value=value})
async function copy(kind:'code'|'url') { try { if (!props.share || !dialogContent.value) return; await copyText(kind==='code'?props.share.code:shareURL.value, dialogContent.value); message.success(kind==='code'?'取件码已复制':'取件链接已复制') } catch(e) { message.info((e as Error).message) } }
</script>
<template><n-modal :show="!!share" @update:show="v => !v && emit('close')"><div ref="dialogContent" class="share-dialog" role="dialog" aria-modal="true" aria-labelledby="share-title" v-if="share"><span class="eyebrow">蓝鸟寄件</span><h2 id="share-title">文件已寄出</h2><p>只分享取件码,或发送链接与二维码</p><div class="share-entry" v-if="share.urls&&share.urls.length>1"><label>分享入口</label><n-select v-model:value="shareURL" :options="share.urls.map(v=>({label:v.label,value:v.url}))"/></div><div class="pickup-code">{{ share.code.slice(0,4) }}-{{ share.code.slice(4,8) }}-{{ share.code.slice(8) }}</div><img v-if="qr" :src="qr" width="232" height="232" alt="取件二维码"/><input class="share-url" :value="shareURL" readonly aria-label="取件链接"/><p class="share-hint">取件码不包含网址;链接和二维码包含完整网址。</p><small>到期时间:{{ date(share.expires) }}</small><div class="actions"><n-button type="primary" @click="copy('code')">复制取件码</n-button><n-button @click="copy('url')">复制取件链接</n-button><n-button @click="emit('close')">完成</n-button></div></div></n-modal></template>
+23
View File
@@ -0,0 +1,23 @@
export interface Policy { anonymous_upload: boolean; anonymous_download: boolean; max_file_bytes: number; max_batch_bytes: number; max_files: number; default_days: number; max_initial_days: number; max_lifetime_days: number; default_downloads: number; max_downloads: number; renewal_days: number; renewal_mode: 'add' | 'sliding'; ip_daily_bytes: number; ip_daily_uploads: number; session_daily_uploads: number; require_password: boolean }
export interface FileRecord { id: string; name: string; size: number; status: string; remaining: number; expires: number; available: boolean }
export interface Parcel { id: string; state: string; created: number; expires: number; count: number; size: number; ip?: string; files?: FileRecord[]; renewal_days?: number }
export interface Share { code: string; url: string; urls?: {label:string;url:string}[]; expires: number }
export interface PublicPolicy { policy: Policy; public_url: string; encryption: boolean; deduplication: boolean; visitor_expires:number }
export class APIError extends Error { constructor(public status: number, public body: Record<string, unknown>) { super(String(body.error ?? '请求失败')) } }
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch('/api/v1' + path, { credentials: 'same-origin', ...options, headers: { 'X-Bluebird-Origin': location.origin, ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers } })
const body = await response.json().catch(() => ({ error: '服务器返回异常响应' }))
if (!response.ok) throw new APIError(response.status, body)
return body as T
}
export const post = <T>(path: string, body: unknown = {}, token?: string) => api<T>(path, { method: 'POST', body: JSON.stringify(body), headers: token ? { Authorization: `Bearer ${token}` } : {} })
export const size = (n: number) => { if (!n) return '0 B'; const i = Math.min(3, Math.floor(Math.log(n) / Math.log(1024))); return (n / 1024 ** i).toFixed(i ? 1 : 0) + ' ' + ['B', 'KiB', 'MiB', 'GiB'][i] }
export const date = (n: number) => n ? new Date(n * 1000).toLocaleString('zh-CN', { hour12: false }) : '—'
export const states: Record<string,string> = { uploading: '上传中', ready: '可取件', revoked: '已撤销', expired: '已过期', exhausted: '已失效', restored: '恢复待审核', deleted: '已清理', pending: '待上传', missing: '文件异常', deleting: '待清理' }
export function upload(path: string, file: File, token: string, progress: (n: number) => void, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', '/api/v1' + path); xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('X-Bluebird-Origin',location.origin); xhr.upload.onprogress = e => progress(e.lengthComputable ? Math.round(e.loaded / e.total * 100) : 0); const abort = () => xhr.abort(); signal.addEventListener('abort', abort, { once: true }); const done = () => signal.removeEventListener('abort', abort); xhr.onload = () => { done(); if (xhr.status >= 200 && xhr.status < 300) resolve(); else { let text = '上传失败'; try { text = JSON.parse(xhr.responseText).error } catch {} reject(new Error(text)) } }; xhr.onerror = () => { done(); reject(new Error('网络中断,请重试')) }; xhr.onabort = () => { done(); reject(new Error('已取消上传')) }; xhr.send(file) })
}
export interface RuntimeSettings { file_budget:number; data_budget:number; min_free_bytes:number; audit_retention_days:number; audit_max_records:number; audit_max_bytes:number }
export interface PageResult<T> { items:T[]; total:number; page:number; page_size:number; pages:number }
export interface AuditEntry { id:number; created:number; action:string; target:string; source:string }
+27
View File
@@ -0,0 +1,27 @@
// LAN HTTP uses a selected readonly field; keep it inside the active modal so
// its focus trap cannot redirect focus and silently copy different content.
export async function copyText(value: string, container: HTMLElement): Promise<void> {
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(value); return } catch {}
}
const previous = document.activeElement
const node = document.createElement('textarea')
node.value = value
node.readOnly = true
node.setAttribute('inputmode', 'none')
// Stay in the viewport for mobile selection. Readonly avoids a soft keyboard;
// 16px avoids iOS zoom, and opacity keeps this temporary field invisible.
node.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;padding:0;border:0;font-size:16px;opacity:0;pointer-events:none'
container.appendChild(node)
try {
node.focus({ preventScroll: true })
node.select()
node.setSelectionRange(0, value.length)
if (document.activeElement !== node || node.selectionStart !== 0 || node.selectionEnd !== value.length || !document.execCommand('copy')) {
throw new Error('复制未完成,请选择或长按取件码、链接手动复制')
}
} finally {
node.remove()
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true })
}
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { createPinia } from 'pinia'
import App from './App.vue'
import './style.css'
const router = createRouter({ history: createWebHashHistory(), routes: [{ path: '/', component: () => import('./Send.vue') }, { path: '/pickup/:code?', component: () => import('./Pickup.vue') }, { path: '/mine', component: () => import('./Mine.vue') }, { path: '/admin', component: () => import('./Admin.vue') }] })
createApp(App).use(router).use(createPinia()).mount('#app')
+26
View File
@@ -0,0 +1,26 @@
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif;color:#1d2939;background:#f5f7fb;font-synthesis:none}*{box-sizing:border-box}body{margin:0}a{color:#1677ff;text-decoration:none}button,input{font:inherit}.site{max-width:1200px;margin:auto;padding:0 36px}.header{height:106px;display:flex;align-items:center;gap:32px;border-bottom:1px solid #e4eaf3}.brand{display:flex;align-items:center;gap:12px;font-size:22px;font-weight:750;color:#1d2939;letter-spacing:-.8px}.brand small{display:block;font-size:11px;letter-spacing:2px;font-weight:450;margin-top:1px;color:#667085}.brand-mark{height:42px;width:42px;background:#1677ff;color:white;border-radius:13px 13px 13px 3px;display:grid;place-items:center;font:italic 28px Georgia}.header nav{display:flex;gap:30px;margin-left:auto}.header nav a{color:#667085;font-size:14px}.header nav a.router-link-exact-active{color:#1677ff;font-weight:700}.admin-link{margin-left:24px;color:#7c8b92;font-size:13px}main{min-height:calc(100vh - 204px);padding-bottom:56px}.intro{padding:58px 0 40px}.eyebrow{display:block;color:#1677ff;letter-spacing:2.4px;font-size:10px;font-weight:700}.intro h1{font-size:46px;line-height:1.3;letter-spacing:-1.8px;margin:14px 0 18px;font-weight:650}.intro h1 span{color:#1677ff}.intro p{color:#667085;font-size:14px}.intro.compact{padding:46px 0 24px}.intro.compact h1{font-size:38px}.card{background:#fff;border:1px solid #e4eaf3;border-radius:18px;padding:28px;box-shadow:0 6px 26px #1677ff08}.send-layout{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(280px,1fr);gap:24px;align-items:start}.card-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:20px}.card-heading h2{font-size:19px;margin:0;font-weight:650}.card-heading>span{font-size:12px;color:#8b989d}.dropzone{border:1.5px dashed #b7d4ff;background:#f7faff;border-radius:13px;padding:30px 16px;text-align:center;cursor:pointer}.dropzone.dragging{border-color:#1677ff;background:#e6f4ff}.upload-glyph{display:grid;place-items:center;border:1px solid #d6e8ff;border-radius:14px;background:white;width:54px;height:54px;margin:auto;font-size:30px;color:#1677ff}.dropzone h3{font-size:16px;font-weight:600;margin:18px 0 6px}.dropzone p{font-size:13px;color:#667085;margin:0 0 15px}.dropzone small{font-size:11px;color:#7a8699}.file-list{margin-top:12px}.file-row{display:flex;align-items:center;gap:14px;padding:16px 0;border-bottom:1px solid #edf1f7;min-width:0}.file-icon{display:grid;place-items:center;flex-shrink:0;width:38px;height:44px;background:#e6f4ff;border-radius:8px;color:#1677ff;font-size:21px}.file-info{min-width:0;flex:1}.file-info strong{font-size:13px;font-weight:550;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-info small{display:block;font-size:11px;color:#87969d;margin:5px 0}.upload-bottom{margin:22px 0 10px;display:flex;align-items:center;justify-content:space-between;gap:16px}.upload-bottom>span{font-size:11px;color:#8d9b9e}.settings-card h2{font-size:19px;margin:12px 0 26px}.settings-card label:not(.n-checkbox){display:flex;justify-content:space-between;align-items:center;font-size:12px;margin:20px 0 9px}.settings-card label span{color:#9aa6aa;font-size:11px}.renew-option{margin-top:24px}.renew-option p,.privacy-note p{font-size:11px;line-height:1.9;color:#88979b;margin:8px 0}.privacy-note{margin-top:25px;border-top:1px solid #e4eaf3;padding-top:20px}.privacy-note strong{font-size:12px;color:#475467}.share-dialog{max-width:420px;width:calc(100vw - 32px);background:white;border-radius:20px;padding:32px;text-align:center}.share-dialog h2{font-size:27px;margin:12px 0}.share-dialog p{color:#88979b;font-size:13px}.pickup-code{font:700 25px monospace;letter-spacing:2px;color:#1677ff;background:#e6f4ff;padding:16px 8px;border-radius:10px;margin:22px 0}.share-dialog img{display:block;margin:12px auto}.share-url{width:100%;font-size:11px;border:1px solid #d6e8ff;border-radius:7px;background:#f7faff;padding:10px;outline:none}.share-dialog small{display:block;color:#8c9ca0;margin:14px 0 20px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.share-dialog .actions{justify-content:center}.notice{padding:12px 16px;background:#e6f4ff;color:#0958d9;border-radius:10px;margin:16px 0;font-size:12px;line-height:1.8}.notice.error{background:#fbefed;color:#b35d52}.pickup-card,.login-card{max-width:680px;margin:auto}.pickup-card .n-button--block{margin-top:22px}.field-label{display:block;font-size:12px;color:#475467;margin-bottom:9px}.password-field{margin-top:20px}.muted{font-size:12px;color:#8b9b9f;line-height:1.8}.pickup-file{padding:22px 0}.parcel-row{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:22px 0;border-top:1px solid #e4eaf3}.parcel-row strong{display:inline-block;margin-left:12px;font-size:14px}.parcel-row small,.parcel-row code{display:block;color:#8c9ca2;font-size:11px;margin-top:9px}.empty{text-align:center;padding:32px;color:#879a9e;font-size:14px}.login-card{max-Line truncated
.admin-badge{margin-left:auto;color:#1677ff;background:#e6f4ff;padding:7px 13px;border-radius:8px;font-size:12px}.brand-mark{background:none;border-radius:12px;flex-shrink:0}.share-hint{font-size:11px!important;line-height:1.8}.settings-explanation{line-height:1.8;font-size:12px;color:#667085}.runtime-grid{margin-top:20px}.settings-section-title{font-size:17px;margin:26px 0 10px}.capacity-details{display:flex;gap:20px;flex-wrap:wrap;font-size:12px;color:#667085;margin-bottom:15px}.receipt-info summary{cursor:pointer;color:#1677ff;font-size:12px}.receipt-info p{font-size:12px;color:#667085;line-height:1.9}.parcel-note{font-size:12px;color:#667085;margin:10px 0}.closed-service{margin-bottom:18px}.capacity-warning{color:#cf1322}.public-nav-hidden{display:none}
.pickup-code{-webkit-user-select:text;user-select:text}
html{scrollbar-gutter:stable}
.admin-panel{display:flex;flex-direction:column;gap:16px;min-width:0}
.admin-panel>.notice{margin:0;flex-shrink:0}
.admin-scroll{min-width:0}
.admin-panel-footer,.admin-pagination{flex-shrink:0;border-top:1px solid #e4eaf3;padding-top:16px}
.admin-filters{display:grid;gap:12px;flex-shrink:0;align-items:end}
.admin-filters>div{min-width:0}.parcel-filters{grid-template-columns:minmax(0,2fr) minmax(150px,1fr) auto}.audit-filters{grid-template-columns:repeat(4,minmax(0,1fr))}.filter-target,.audit-filters .filter-actions{grid-column:span 2}
.filter-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.filter-date{width:100%;height:34px;padding:0 9px;border:1px solid #e0e0e6;border-radius:8px;color:#1d2939;background:white;font:inherit;font-size:13px;min-width:0}
.filter-date:focus,.admin-pagination select:focus,.page-jump input:focus{outline:2px solid #91caff;outline-offset:1px}
.admin-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;color:#667085;font-size:12px}
.page-size,.page-jump,.page-jump label{display:flex;align-items:center;gap:8px}.page-size select,.page-jump input{border:1px solid #d6e8ff;border-radius:6px;height:30px;padding:0 6px;background:#fff;color:#1d2939;font:inherit}.page-jump input{width:60px}.page-size select{width:62px}.page-buttons{flex-wrap:nowrap}.page-buttons span{white-space:nowrap}
.audit-summary{margin:0;color:#667085;font-size:12px;line-height:1.8;flex-shrink:0}
.audit-table{width:100%;border-collapse:separate;border-spacing:0;table-layout:fixed;font-size:12px}.audit-table th{position:sticky;top:0;text-align:left;padding:12px 10px;background:#f7faff;color:#667085;z-index:1;border-bottom:1px solid #e4eaf3;font-weight:550}.audit-table th:nth-child(1){width:22%}.audit-table th:nth-child(2){width:23%}.audit-table th:nth-child(3){width:33%}.audit-table th:nth-child(4){width:22%}.audit-table td{padding:14px 10px;border-bottom:1px solid #edf1f7;vertical-align:top;overflow-wrap:anywhere;color:#475467}.audit-table strong{display:block;color:#0958d9;font-weight:550}.audit-table small{display:block;color:#8b9b9f;font-size:11px;margin-top:5px}.audit-table code{font-size:11px}
.admin-panel .admin-parcel:first-child{padding-top:8px}.admin-parcel>code{overflow-wrap:anywhere}
@media(max-width:900px){.audit-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.parcel-filters{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}.parcel-filters .filter-actions{grid-column:1/-1}}
@media(max-width:600px){.admin-toolbar{flex-wrap:wrap}.admin-toolbar>.muted{width:100%;text-align:right}.filter-date{font-size:16px}.admin-pagination{gap:10px}.page-jump{margin-left:auto}.page-buttons{width:100%;justify-content:center;order:3}.audit-table thead{display:none}.audit-table,.audit-table tbody,.audit-table tr,.audit-table td{display:block;width:100%}.audit-table tr{padding:10px 0;border-bottom:1px solid #e4eaf3}.audit-table td{padding:4px 0;border:0;display:grid;grid-template-columns:70px minmax(0,1fr);gap:6px}.audit-table td::before{content:attr(data-label);color:#8b9b9f;font-size:11px}.audit-table td small{grid-column:2;margin:0}.audit-filters .filter-actions{grid-column:1/-1}.audit-filters .filter-actions>.muted{font-size:11px}.admin-filters{gap:10px}.page-size select,.page-jump input{font-size:16px}}
@media(max-width:600px){.audit-filters>div:nth-child(-n+2){grid-column:1/-1}.admin-management .n-tabs{--n-tab-gap:16px!important;--n-tab-font-size:12px!important}}
.share-entry{text-align:left;margin-top:20px}.share-entry label{display:block;margin-bottom:8px;font-size:12px;color:#667085}
+80
View File
@@ -0,0 +1,80 @@
import { test, expect, chromium } from '@playwright/test'
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:https'
import { request } from 'node:http'
test('LAN works without public DNS; public HTTP and HTTPS share files through a remote proxy', async () => {
test.setTimeout(60_000)
const root=mkdtempSync(join(tmpdir(),'bluebird-multiple-access-'))
const binary=resolve('../build/bluebird'), config=join(root,'config.toml')
const lan='http://127.0.0.1:19081', http='http://files.example.test:19081', https='https://files.example.test:19082'
writeFileSync(config,readFileSync('../deploy/config.example.toml','utf8')
.replace('listen = "127.0.0.1:8080"','listen = "127.0.0.1:19081"')
.replace('public_url = "http://localhost:8080"',`public_url = "${https}"`)
.replace('access_urls = []',`access_urls = ["${http}", "${lan}"]`)
.replace('secure_cookies = false','secure_cookies = true')
.replace('allow_lan_http = false','allow_lan_http = true')
.replace('trusted_proxies = []','trusted_proxies = ["127.0.0.1/32"]')
.replace('min_free_bytes = 1073741824','min_free_bytes = 0'))
execFileSync(binary,['init','--config',config],{env:{...process.env,BLUEBIRD_ADMIN_PASSWORD:'multiple-access-password'}})
execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-keyout',join(root,'key.pem'),'-out',join(root,'cert.pem'),'-days','1','-subj','/CN=files.example.test'],{stdio:'ignore'})
let worker:ChildProcess|undefined
const proxy=createServer({key:readFileSync(join(root,'key.pem')),cert:readFileSync(join(root,'cert.pem'))},(incoming,outgoing)=>{
const upstream=request(lan+incoming.url,{method:incoming.method,headers:{...incoming.headers,host:'127.0.0.1:19081','x-forwarded-host':incoming.headers.host,'x-forwarded-proto':'https','x-forwarded-for':'192.168.1.61'}},response=>{
outgoing.writeHead(response.statusCode!,response.headers);response.pipe(outgoing)
});upstream.on('error',()=>{outgoing.writeHead(502);outgoing.end()});incoming.pipe(upstream)
})
const browser=await chromium.launch({env:{...process.env,HTTP_PROXY:'',HTTPS_PROXY:'',ALL_PROXY:'',http_proxy:'',https_proxy:'',all_proxy:''},args:['--host-resolver-rules=MAP files.example.test 127.0.0.1','--no-proxy-server']})
try {
worker=spawn(binary,['serve','--config',config],{stdio:'ignore'})
await expect.poll(async()=>{try{return(await fetch(lan+'/api/v1/health')).status}catch{return 0}}).toBe(200)
await new Promise<void>(ready=>proxy.listen(19082,'127.0.0.1',ready))
const context=await browser.newContext({ignoreHTTPSErrors:true}), page=await context.newPage()
page.setDefaultTimeout(10_000)
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
// Abort every request to the public hostname: the LAN entrance must remain usable.
await page.route('**://files.example.test:*/**',route=>route.abort('namenotresolved'))
await page.goto(lan);await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible()
await page.locator('input[type=file]').setInputFiles({name:'three-entrances.txt',mimeType:'text/plain',buffer:Buffer.from('same file in LAN, HTTP and HTTPS')})
await page.getByRole('button',{name:'上传并生成取件码'}).click()
await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible()
const lanLink=await page.getByRole('textbox',{name:'取件链接'}).inputValue()
expect(lanLink.startsWith(lan+'/#/pickup/')).toBeTruthy()
const qrBefore=await page.getByAltText('取件二维码').getAttribute('src')
await page.locator('.share-entry .n-base-selection').click()
await page.getByText('公网HTTPS · '+https,{exact:true}).click()
const httpsLink=await page.getByRole('textbox',{name:'取件链接'}).inputValue()
expect(httpsLink).toBe(lanLink.replace(lan,https))
await expect.poll(()=>page.getByAltText('取件二维码').getAttribute('src')).not.toBe(qrBefore)
await page.getByRole('button',{name:'完成',exact:true}).click()
await page.unroute('**://files.example.test:*/**')
for(const base of [lan,https,http]) {
if(base!==lan){
await page.goto(base);await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible()
await page.locator('input[type=file]').setInputFiles({name:'public-upload.txt',mimeType:'text/plain',buffer:Buffer.from('public upload')})
await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible()
expect((await page.getByRole('textbox',{name:'取件链接'}).inputValue()).startsWith(base+'/#/pickup/')).toBeTruthy()
await page.getByRole('button',{name:'完成',exact:true}).click()
}
await page.goto(lanLink.replace(lan,base))
await expect(page.getByRole('heading',{name:'你的文件已就位'})).toBeVisible()
const pending=page.waitForEvent('download');await page.getByRole('button',{name:'下载',exact:true}).click()
expect(readFileSync((await (await pending).path())!,'utf8')).toBe('same file in LAN, HTTP and HTTPS')
await page.goto(base+'/#/admin');await page.getByRole('textbox').first().fill('admin');await page.locator('input[type=password]').fill('multiple-access-password')
await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible()
}
const cookies=await context.cookies(https+'/api/v1/admin/status')
expect(cookies.some(c=>c.name==='bb_admin'&&c.secure)).toBeTruthy()
expect(cookies.some(c=>c.name==='bb_admin_http'&&!c.secure)).toBeTruthy()
await page.goto(http+'/#/admin');await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible()
expect((await context.cookies(http+'/api/v1/admin/status')).some(c=>c.name==='bb_admin')).toBeFalsy()
expect(errors).toEqual([])
} finally {
await browser.close();proxy.closeAllConnections();await new Promise<void>(done=>proxy.close(()=>done()))
if(worker&&worker.exitCode===null){worker.kill('SIGTERM');await new Promise(done=>worker!.once('exit',done))}
rmSync(root,{recursive:true,force:true})
}
})
+92
View File
@@ -0,0 +1,92 @@
import { test as base, expect, type Page, type Cookie } from '@playwright/test'
// Reuse one administrator login for UI fixtures, preserving the real login
// rate limiter and leaving its remaining burst for the full transfer scenario.
const test=base.extend<{}, {adminCookies:Cookie[]}>({
adminCookies:[async({browser},use)=>{
const context=await browser.newContext()
try{
const response=await context.request.post('http://127.0.0.1:18981/api/v1/admin/login',{headers:{Origin:'http://127.0.0.1:18981'},data:{username:'admin',password:'e2e-secret-password'}})
expect(response.status()).toBe(200)
await use(await context.cookies())
}finally{await context.close()}
},{scope:'worker'}]
})
async function adminFixture(page: Page) {
const parcelQueries: URLSearchParams[] = [], auditQueries: URLSearchParams[] = []
await page.route('**/api/v1/admin/parcels?**', async route => {
const q = new URL(route.request().url()).searchParams; parcelQueries.push(q)
const size = Number(q.get('page_size') ?? 10), total = q.get('q') ? 1 : 23, pages = Math.max(1, Math.ceil(total / size)), current = Math.min(Number(q.get('page') ?? 1), pages)
const items = Array.from({length: Math.min(size, total-(current-1)*size)}, (_,i) => {
const n = (current-1)*size+i+1
return { id: n.toString(16).padStart(32,'0'), state:'ready', ip:'203.0.113.1', created:1791187200, expires:1791792000, count:1, size:12, files:[{id:'file-'+n,name:'样例文件-'+n+'.txt',size:12,status:'ready',remaining:5,expires:1791792000}] }
})
await route.fulfill({json:{items,total,page:current,page_size:size,pages}})
})
await page.route('**/api/v1/admin/audit?**', async route => {
const q = new URL(route.request().url()).searchParams; auditQueries.push(q)
const size = Number(q.get('page_size') ?? 20), total = q.get('source') ? (q.get('source')==='no-match' ? 0 : 2) : 100000, pages = Math.max(1,Math.ceil(total/size)), current = Math.min(Number(q.get('page') ?? 1),pages)
const items = Array.from({length:Math.min(size,Math.max(0,total-(current-1)*size))}, (_,i) => ({id:total-(current-1)*size-i,created:1791187200,action:q.get('action')||'admin.login',target:q.get('target')||'audit-'+(total-(current-1)*size-i),source:q.get('source')||'203.0.113.1'}))
await route.fulfill({json:{items,total,page:current,page_size:size,pages}})
})
await page.goto('/#/admin')
await expect(page.getByRole('tab',{name:'文件管理'})).toBeVisible()
await expect(page.getByRole('navigation',{name:'文件管理分页'})).toContainText('共 23 个包裹')
return {parcelQueries,auditQueries}
}
for(const mobile of [false,true]) {
test(`admin tabs expand naturally and lists paginate/filter (${mobile?'mobile':'desktop'})`,async({page,adminCookies})=>{
await page.context().addCookies(adminCookies)
if(mobile)await page.setViewportSize({width:390,height:844})
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
const {parcelQueries,auditQueries}=await adminFixture(page)
const pager=page.getByRole('navigation',{name:'文件管理分页'})
await expect(page.locator('.admin-parcel:visible')).toHaveCount(10)
await pager.getByRole('button',{name:'上一页',exact:true}).isDisabled().then(v=>expect(v).toBeTruthy())
await pager.getByRole('button',{name:'下一页',exact:true}).click()
await expect(pager).toContainText('第 2 / 3 页');await expect(page.getByText('样例文件-11.txt',{exact:true})).toBeVisible()
expect(parcelQueries.at(-1)?.get('page')).toBe('2')
await pager.getByRole('button',{name:'下一页',exact:true}).click()
await expect(page.locator('.admin-parcel:visible')).toHaveCount(3)
await expect(pager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
await page.getByLabel('文件名或包裹 ID',{exact:true}).fill('样例文件-2')
await page.locator('.parcel-filters').getByRole('button',{name:'搜索',exact:true}).click()
await expect(pager).toContainText('共 1 个包裹');await expect(pager).toContainText('第 1 / 1 页')
expect(parcelQueries.at(-1)?.get('q')).toBe('样例文件-2')
await expect(pager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
await page.locator('.parcel-filters').getByRole('button',{name:'重置',exact:true}).click()
await expect(pager).toContainText('共 23 个包裹')
for(const name of ['文件管理','服务策略','容量与保留','操作审计']){
await page.getByRole('tab',{name,exact:true}).click()
expect(await page.locator('.admin-panel:visible .admin-scroll').evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBeTruthy()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBeTruthy()
}
const auditPager=page.getByRole('navigation',{name:'操作审计分页'})
await expect(page.locator('.audit-table tbody tr')).toHaveCount(20)
await expect(auditPager).toContainText('共 100000 条')
await expect(auditPager).toContainText('第 1 / 5000 页')
await auditPager.getByRole('button',{name:'下一页',exact:true}).click()
await expect(auditPager).toContainText('第 2 / 5000 页');expect(auditQueries.at(-1)?.get('page')).toBe('2')
await page.getByLabel('操作审计分页跳转页码',{exact:true}).fill('5000')
await auditPager.getByRole('button',{name:'跳转',exact:true}).click()
await expect(auditPager).toContainText('第 5000 / 5000 页');await expect(auditPager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
await page.getByLabel('开始时间',{exact:true}).fill('2026-10-05T00:00');await page.getByLabel('结束时间',{exact:true}).fill('2026-10-06T00:00')
await page.getByLabel('来源 IP',{exact:true}).fill('203.0.113')
await page.getByLabel('目标标识',{exact:true}).fill('target-fragment')
await page.locator('.audit-filters .n-base-selection').click()
await page.getByText('删除包裹 · admin.delete',{exact:true}).click()
await page.locator('.audit-filters').getByRole('button',{name:'搜索',exact:true}).click()
await expect(auditPager).toContainText('共 2 条');await expect(auditPager).toContainText('第 1 / 1 页')
const query=auditQueries.at(-1)!;expect(query.get('action')).toBe('admin.delete');expect(query.get('source')).toBe('203.0.113');expect(query.get('target')).toBe('target-fragment');expect(query.get('from')).toBe(await page.evaluate(()=>String(new Date('2026-10-05T00:00').getTime()/1000)));expect(query.get('to')).toBe(await page.evaluate(()=>String(new Date('2026-10-06T00:00').getTime()/1000)))
await expect(page.locator('.audit-table tbody tr')).toHaveCount(2)
await page.getByLabel('来源 IP',{exact:true}).fill('no-match');await page.locator('.audit-filters').getByRole('button',{name:'搜索',exact:true}).click()
await expect(page.getByText('没有符合条件的审计记录。',{exact:true})).toBeVisible();await expect(auditPager).toContainText('共 0 条');await expect(auditPager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
await page.locator('.audit-filters').getByRole('button',{name:'重置',exact:true}).click()
await expect(auditPager).toContainText('共 100000 条');expect(auditQueries.at(-1)?.has('source')).toBeFalsy()
await page.getByLabel('操作审计分页每页条数',{exact:true}).selectOption('50');await expect(page.locator('.audit-table tbody tr')).toHaveCount(50);await expect(auditPager).toContainText('第 1 / 2000 页')
expect(errors).toEqual([])
})
}
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test'
import { readFileSync } from 'node:fs'
test('anonymous batch → share QR → pickup download → admin management',async({page})=>{
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
await page.goto('/');await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible();await expect(page).toHaveTitle('蓝鸟 · 文件快递柜');await expect(page.getByRole('link',{name:'管理',exact:true})).toHaveCount(0)
await page.locator('input[type=file]').setInputFiles([{name:'hello.txt',mimeType:'text/plain',buffer:Buffer.from('Hello Bluebird')},{name:'copy.txt',mimeType:'text/plain',buffer:Buffer.from('Hello Bluebird')}])
await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();await expect(page.getByAltText('取件二维码')).toBeVisible();await expect(page.getByRole('button',{name:'复制取件码',exact:true})).toBeVisible()
const url=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.goto(url)
await expect(page.getByRole('heading',{name:'你的文件已就位'})).toBeVisible();const downloadPromise=page.waitForEvent('download');await page.getByRole('button',{name:'下载',exact:true}).first().click();const file=await downloadPromise;expect(file.suggestedFilename()).toBe('hello.txt');expect(readFileSync((await file.path())!,'utf8')).toBe('Hello Bluebird')
await page.getByRole('button',{name:'刷新状态'}).click();await expect(page.getByText('14 B · 剩余 4 次')).toBeVisible()
await page.goto('/#/admin');await page.getByRole('textbox').first().fill('admin');await page.locator('input[type=password]').fill('e2e-secret-password');await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible();await expect(page.getByRole('link',{name:'寄文件',exact:true})).toHaveCount(0);await expect(page).toHaveTitle('管理控制台 · 蓝鸟');await expect(page.getByText('hello.txt',{exact:true})).toBeVisible();await expect(page.getByText('1 个对象 · 2 份文件记录')).toBeVisible()
await page.getByRole('tab',{name:'服务策略'}).click();await expect(page.getByText('匿名会话日寄件次数',{exact:true})).toBeVisible();await page.getByRole('button',{name:'保存策略'}).click();await expect(page.getByText('策略已保存,新上传会采用新策略')).toBeVisible()
await page.getByRole('tab',{name:'容量与保留'}).click();await expect(page.getByText('文件存储预算(MiB)',{exact:true})).toBeVisible();await expect(page.getByText('审计保留天数',{exact:true})).toBeVisible();expect(errors).toEqual([])
})
test('mobile upload/pickup views remain within viewport',async({page})=>{
await page.setViewportSize({width:390,height:844});await page.goto('/');await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy();await page.getByRole('link',{name:'取文件',exact:true}).click();await expect(page.getByRole('button',{name:'查找文件'})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy()
})
test('anonymous visitor can revoke their own parcel without administrator login',async({page})=>{
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'revoke.txt',mimeType:'text/plain',buffer:Buffer.from('revoke me')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const url=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.getByRole('link',{name:'我的寄件',exact:true}).click();await expect(page.getByText('1 个文件 · 9 B')).toBeVisible();await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).last().click();await expect(page.getByText('已撤销',{exact:true})).toBeVisible();await page.goto(url);await expect(page.getByRole('alert')).toContainText('取件信息无效或已失效')
})
test('management receipt works after changing anonymous browser session',async({page,browser})=>{
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'managed.txt',mimeType:'text/plain',buffer:Buffer.from('manage with receipt')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const shareUrl=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.getByRole('link',{name:'我的寄件',exact:true}).click();const exported=page.waitForEvent('download');await page.getByRole('button',{name:'导出管理凭据',exact:true}).click();const receipt=await exported;const contents=readFileSync((await receipt.path())!)
const context=await browser.newContext();const recipient=await context.newPage();await recipient.goto('http://127.0.0.1:18981/#/mine');await recipient.locator('input[type=file]').setInputFiles({name:'receipt.json',mimeType:'application/json',buffer:contents});await expect(recipient.getByText('管理凭据已导入,仅保存在当前标签页')).toBeVisible();await recipient.getByRole('button',{name:'撤销',exact:true}).click();await recipient.getByRole('button',{name:'撤销',exact:true}).last().click();await expect(recipient.getByText('已撤销',{exact:true})).toBeVisible();await recipient.goto(shareUrl);await expect(recipient.getByRole('alert')).toContainText('取件信息无效或已失效');await context.close()
})
test('secure context copies the actual code with Clipboard API',async({page,context})=>{
await context.grantPermissions(['clipboard-read','clipboard-write'])
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'copy-code.txt',mimeType:'text/plain',buffer:Buffer.from('copy code only')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const code=(await page.locator('.pickup-code').innerText()).replaceAll('-','');await page.getByRole('button',{name:'复制取件码',exact:true}).click();expect(await page.evaluate(()=>navigator.clipboard.readText())).toBe(code);await expect(page.getByText('取件码已复制',{exact:true})).toBeVisible();const icon=await page.request.get('/favicon.ico');expect(icon.status()).toBe(200);
})
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, devices, type Page } from '@playwright/test'
// These tests exercise real browser clipboard operations. Upload responses are
// fixtures; the end-to-end cabinet tests cover actual transfers independently.
async function mockShareUpload(page: Page) {
await page.route('**/api/v1/uploads**', async route => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/publish')) await route.fulfill({ json: { code: '7K3MP9TX2R', url: 'http://127.0.0.1:18981/#/pickup/7K3MP9TX2R', expires: 2000000000 } })
else if (path.endsWith('/uploads')) await route.fulfill({ json: { id: 'a'.repeat(32), manage_token: 'b'.repeat(43), files: [{ id: 'c'.repeat(32) }] } })
else if (route.request().method() === 'PUT') await route.fulfill({ json: {} })
else await route.abort()
})
}
async function openShare(page: Page) {
await mockShareUpload(page)
await page.goto('http://127.0.0.1:18981/')
await page.locator('input[type=file]').setInputFiles({ name: 'clipboard.txt', mimeType: 'text/plain', buffer: Buffer.from('clipboard regression') })
await page.getByRole('button', { name: '上传并生成取件码' }).click()
await expect(page.getByRole('heading', { name: '文件已寄出' })).toBeVisible()
}
const profiles = [
{ name: 'desktop HTTP fallback', device: undefined, mode: 'absent' },
{ name: 'Android touch HTTP fallback', device: devices['Pixel 7'], mode: 'absent' },
// Chromium emulates the iPhone viewport/touch configuration, not Safari's engine.
{ name: 'iPhone touch layout HTTP fallback', device: devices['iPhone 13'], mode: 'absent' },
{ name: 'Android touch denied Clipboard API', device: devices['Pixel 7'], mode: 'denied' },
{ name: 'Android touch secure Clipboard API', device: devices['Pixel 7'], mode: 'native' },
]
for (const profile of profiles) {
test(`actual clipboard copy and paste (${profile.name})`, async ({ browser }) => {
const context = await browser.newContext(profile.device ?? {})
try {
const page = await context.newPage()
await mockShareUpload(page)
// Localhost exposes Clipboard API. Reproduce its absence on LAN HTTP or its
// rejection without replacing the browser's actual copy/paste operations.
if (profile.mode !== 'native') await page.addInitScript(mode => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: mode === 'absent' ? undefined : { writeText: async () => { throw new DOMException('Denied', 'NotAllowedError') } } })
}, profile.mode)
await page.goto('http://127.0.0.1:18981/')
await page.evaluate(() => {
const input = document.createElement('textarea')
input.id = 'clipboard-seed'
input.value = 'previous clipboard content'
document.body.appendChild(input)
})
await page.locator('#clipboard-seed').selectText()
await page.keyboard.press('Control+c')
await page.locator('#clipboard-seed').fill('')
await page.keyboard.press('Control+v')
await expect(page.locator('#clipboard-seed')).toHaveValue('previous clipboard content')
await page.locator('#clipboard-seed').evaluate(node => node.remove())
// Preserve this document's seeded clipboard and upload through the normal UI.
await page.locator('input[type=file]').setInputFiles({ name: 'clipboard.txt', mimeType: 'text/plain', buffer: Buffer.from('clipboard regression') })
await page.getByRole('button', { name: '上传并生成取件码' }).click()
await expect(page.getByRole('heading', { name: '文件已寄出' })).toBeVisible()
const code = (await page.locator('.pickup-code').innerText()).replaceAll('-', '')
expect(code).toMatch(/^[0-9A-Z]{10}$/)
const url = await page.getByRole('textbox', { name: '取件链接' }).inputValue()
await page.locator('.share-dialog').evaluate(node => {
const input = document.createElement('input')
input.setAttribute('aria-label', '粘贴检查')
node.appendChild(input)
})
for (const [name, value] of [['复制取件码', code], ['复制取件链接', url]]) {
const button = page.getByRole('button', { name, exact: true })
if (profile.device) await button.tap(); else await button.click()
await expect(page.getByText(name === '复制取件码' ? '取件码已复制' : '取件链接已复制', { exact: true })).toBeVisible()
const paste = page.getByRole('textbox', { name: '粘贴检查' })
await paste.fill('')
await paste.focus()
await expect(paste).toBeFocused()
// Native paste reads the actual clipboard, including in touch emulation.
await page.keyboard.press('Control+v')
await expect(paste).toHaveValue(value)
await expect(page.locator('.share-dialog textarea')).toHaveCount(0)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy()
} finally { await context.close() }
})
}
test('blocked copy offers manual copying without a success message', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
const native = document.execCommand.bind(document)
document.execCommand = (command, showUI, value) => command === 'copy' ? false : native(command, showUI, value)
})
await openShare(page)
await page.getByRole('button', { name: '复制取件码', exact: true }).click()
await expect(page.getByText('复制未完成,请选择或长按取件码、链接手动复制', { exact: true })).toBeVisible()
await expect(page.getByText('取件码已复制', { exact: true })).toHaveCount(0)
await expect(page.locator('.share-dialog textarea')).toHaveCount(0)
await page.locator('.pickup-code').selectText()
expect(await page.evaluate(() => getSelection()?.toString())).toBe(await page.locator('.pickup-code').innerText())
})
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"skipLibCheck":true,"lib":["ES2022","DOM","DOM.Iterable"],"types":["vite/client","node"],"esModuleInterop":true},"include":["src/**/*.ts","src/**/*.vue","vite.config.ts"]}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], build: { outDir: '../internal/web/dist', emptyOutDir: true }, server: { proxy: { '/api': 'http://127.0.0.1:8080' } } })