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