26.9(安全审计修复版)

Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
2026-09-05 04:22:41 +08:00
commit 7f060dd0e4
173 changed files with 32455 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
// 文件快递柜 Go 重写版服务入口:
// 装配 配置 → Postgres → 缓存 → 设置 → 审计 → 存储引擎 → 中间件 → API 路由。
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"filecodebox/internal/api"
"filecodebox/internal/audit"
"filecodebox/internal/cache"
"filecodebox/internal/config"
"filecodebox/internal/database"
"filecodebox/internal/janitor"
"filecodebox/internal/middleware"
"filecodebox/internal/settings"
"filecodebox/internal/storage"
)
// APP_VERSION 版本号,对齐参考仓库 VERSION。
const APP_VERSION = "2.5.6"
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// 1. 配置(环境变量基线;引擎相关 env 种子在此注入,DB KV 仍可覆盖)
cfg, err := config.New()
if err != nil {
log.Fatalf("[boot] 配置加载失败: %v", err)
}
applyEnvEngineSeeds(cfg)
// 2. 数据库(需求 ⑧:默认 SQLite 零依赖;配置 FCB_DB_DRIVER=postgres+DSN 后走 Postgres
db, err := database.Open(ctx, database.Options{Driver: cfg.Env.DBDriver, DSN: cfg.Env.DBDSN})
if err != nil {
log.Fatalf("[boot] 数据库初始化失败: %v", err)
}
defer func() { _ = database.Close(db) }()
if err := database.Migrate(ctx, db); err != nil {
log.Fatalf("[boot] 数据库迁移失败: %v", err)
}
if cfg.Env.DBDriver == config.DBDriverPostgres {
log.Printf("[boot] Postgres 数据库已启用")
} else {
log.Printf("[boot] SQLite 数据库: %s", cfg.SQLitePath())
}
// 3. 缓存:Redis 可选,未配置则内存降级(需求 ⑧:默认即内存,Redis 为增强项)
cacheImpl, err := cache.New(ctx, cache.RedisOptions{Addr: cfg.Env.RedisAddr, DB: cfg.Env.RedisDB})
if err != nil {
log.Printf("[boot] Redis 连接失败,降级为内存缓存: %v", err)
cacheImpl, err = cache.NewMemory(), nil
if err != nil {
log.Fatalf("[boot] 内存缓存初始化失败: %v", err)
}
}
defer func() { _ = cacheImpl.Close() }()
if cfg.Env.RedisAddr != "" {
if cfg.Env.RedisDB != 0 {
log.Printf("[boot] Redis 缓存已启用: %s (db=%d)", cfg.Env.RedisAddr, cfg.Env.RedisDB)
} else {
log.Printf("[boot] Redis 缓存已启用: %s", cfg.Env.RedisAddr)
}
} else {
log.Println("[boot] 未配置 FCB_REDIS_ADDR,使用内存缓存")
}
// 4. 设置管理器(env 基线 + DB settings KV 运行时覆盖)
mgr, err := settings.NewManager(ctx, db, cfg)
if err != nil {
log.Fatalf("[boot] 设置加载失败: %v", err)
}
mgr.SystemStart(ctx)
autoInitIfNeeded(mgr)
// 5. 审计服务 + 重试循环(需求 ③)
auditSvc := audit.NewService(audit.NewDBSink(db))
auditStop := make(chan struct{})
defer close(auditStop)
auditSvc.StartRetryLoop(auditStop)
// 6. 限流器(规则来自 settings KV,可被管理端动态调整)
limiter := middleware.NewRateLimiter(cacheImpl, map[string]middleware.LimitRule{
middleware.LimitError: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
middleware.LimitUpload: {Count: cfg.GetInt("uploadCount"), Window: minutes(cfg.GetInt("uploadMinute"))},
middleware.LimitLogin: {Count: cfg.GetInt("loginCount"), Window: minutes(cfg.GetInt("loginMinute"))},
middleware.LimitMeta: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
})
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;v3 包装为可热切换 Manager)
storage.SetEngineOptions(buildEngineOptions(cfg))
bootStore, err := storage.NewEngine(ctx, cfg.Engine())
if err != nil {
log.Fatalf("[boot] 存储引擎 %s 初始化失败: %v", cfg.Engine(), err)
}
if err := bootStore.HealthCheck(ctx); err != nil {
// 预检失败仅告警不阻断启动(存储可能在运行中恢复)
log.Printf("[boot] 警告: 存储引擎 %s 健康检查未通过: %v", cfg.Engine(), err)
} else {
log.Printf("[boot] 存储引擎 %s 健康检查通过", cfg.Engine())
}
// v3Manager 包装——保存/读取委托当前引擎;管理端可热切换
//(构建闭包在每次切换前用最新 KV 刷新 EngineOptions,参数改动即时生效)
store := storage.NewManager(cfg.Engine(), bootStore, func(name string) (storage.Storage, error) {
storage.SetEngineOptions(buildEngineOptions(cfg))
return storage.NewEngine(ctx, name)
})
// 8. 路由装配
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.Use(middleware.ClientIP(middleware.ParseTrustedProxies(cfg.Env.TrustedProxies)))
// L6:管理端 CORS 收紧(允许站点对外域名 site_domain 跨域调用,其余拒绝)
r.Use(middleware.Cors(cfg.SiteDomain()))
// 未初始化守卫:除 /setup 与 /api/v1/health 外一律 428
r.Use(middleware.GuardNotInitialized(mgr.IsInitialized))
// M3:全局请求体大小上限(管理端 1MiB;上传类 = 单文件上限 + 表单开销)
r.Use(middleware.BodyLimit(bodyLimitFn(cfg)))
// 审计中间件:按 DefaultClassifier 路由模式自动分类 upload/download(需求 ③)
r.Use(middleware.Audit(auditSvc, nil))
api.Register(r, &api.Deps{
DB: db,
Cfg: cfg,
Mgr: mgr,
AuditSvc: auditSvc,
Limiter: limiter,
Store: store,
Version: APP_VERSION,
})
// 9. HTTP 服务
// M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑
janitor.Start(ctx, db, store, 10*time.Minute)
srv := &http.Server{
Addr: cfg.Env.Listen,
Handler: r,
ReadHeaderTimeout: 15 * time.Second,
}
go func() {
log.Printf("[boot] 文件快递柜 %s 启动于 %s(存储引擎 %s)", APP_VERSION, cfg.Env.Listen, cfg.Engine())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[boot] HTTP 服务异常退出: %v", err)
}
}()
<-ctx.Done()
log.Println("[boot] 收到退出信号,开始优雅关闭...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("[boot] 优雅关闭超时: %v", err)
}
log.Println("[boot] 服务已退出")
}
// autoInitIfNeeded 安全审计 L1:系统未初始化且配置了 FCB_ADMIN_PASSWORD 时,
// 启动即自动完成管理员初始化,消除「部署到公网后被抢先访问 /setup 接管」的窗口。
// 密码不足 8 位时拒绝并保持 /setup 可用(不静默采用弱口令)。
func autoInitIfNeeded(mgr *settings.Manager) {
if mgr.IsInitialized() {
return
}
pwd := strings.TrimSpace(os.Getenv("FCB_ADMIN_PASSWORD"))
if pwd == "" {
return
}
if len(pwd) < 8 {
log.Printf("[boot] 警告: FCB_ADMIN_PASSWORD 少于 8 位,已忽略;/setup 初始化向导保持可用")
return
}
ctx := context.Background()
if err := mgr.UpdateKV(ctx, map[string]any{
"admin_token": settings.HashPassword(pwd),
"jwt_secret": settings.GenerateJWTSecret(),
}); err != nil {
log.Printf("[boot] 警告: FCB_ADMIN_PASSWORD 自动初始化写入失败: %v/setup 仍可用)", err)
return
}
if err := mgr.Reload(ctx); err != nil {
log.Printf("[boot] 警告: 自动初始化后配置重载失败: %v", err)
return
}
log.Printf("[boot] 已通过 FCB_ADMIN_PASSWORD 自动完成管理员初始化")
}
// envKVSeeds 容器编排常用的 FCB_* 环境变量 → settings KV 键映射。
// config 包只解析进程必需的 5 个变量;引擎相关配置通过本桥接注入,
// 使 docker-compose 无需进库即可完成引擎配置。优先级:defaults < 环境变量种子 < DB settings KV。
var envKVSeeds = []struct {
envKey string
kvKey string
}{
{"FCB_LOCAL_STORAGE_PATH", "local_storage_path"},
{"FCB_STORAGE_PATH", "storage_path"},
{"FCB_S3_ACCESS_KEY_ID", "s3_access_key_id"},
{"FCB_S3_SECRET_ACCESS_KEY", "s3_secret_access_key"},
{"FCB_AWS_SESSION_TOKEN", "aws_session_token"},
{"FCB_S3_BUCKET_NAME", "s3_bucket_name"},
{"FCB_S3_ENDPOINT_URL", "s3_endpoint_url"},
{"FCB_S3_REGION_NAME", "s3_region_name"},
{"FCB_S3_ADDRESSING_STYLE", "s3_addressing_style"},
{"FCB_WEBDAV_URL", "webdav_url"},
{"FCB_WEBDAV_USERNAME", "webdav_username"},
{"FCB_WEBDAV_PASSWORD", "webdav_password"},
{"FCB_WEBDAV_ROOT_PATH", "webdav_root_path"},
}
// applyEnvEngineSeeds 把已设置的环境变量作为 KV 种子写入配置(仅种子,不落库)。
func applyEnvEngineSeeds(cfg *config.Config) {
seeds := map[string]any{}
for _, m := range envKVSeeds {
if v := strings.TrimSpace(os.Getenv(m.envKey)); v != "" {
seeds[m.kvKey] = v
}
}
// L8:管理员会话有效期支持环境变量(秒;1~365 整天校验在 AdminSessionExpireSeconds
if v := strings.TrimSpace(os.Getenv("FCB_ADMIN_SESSION_EXPIRE")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
seeds["adminSessionExpire"] = n
}
}
if len(seeds) > 0 {
cfg.ApplyKV(seeds)
log.Printf("[boot] 已从环境变量注入 %d 项配置种子", len(seeds))
}
}
// buildEngineOptions 从配置构造引擎选项(对齐 go-storage 的注入约定:
// 必须在 NewEngine 之前调用;字段来源为 config KV,键名对齐参考实现)。
func buildEngineOptions(cfg *config.Config) storage.EngineOptions {
return storage.EngineOptions{
Local: storage.LocalOptions{
Root: cfg.GetString("local_storage_path"),
},
S3: storage.S3Options{
AccessKeyID: cfg.GetString("s3_access_key_id"),
SecretAccessKey: cfg.GetString("s3_secret_access_key"),
SessionToken: cfg.GetString("aws_session_token"),
Bucket: cfg.GetString("s3_bucket_name"),
Endpoint: cfg.GetString("s3_endpoint_url"),
Region: cfg.GetString("s3_region_name"),
AddressingStyle: cfg.GetString("s3_addressing_style"),
},
WebDAV: storage.WebDAVOptions{
BaseURL: cfg.GetString("webdav_url"),
Username: cfg.GetString("webdav_username"),
Password: cfg.GetString("webdav_password"),
RootPath: cfg.GetString("webdav_root_path"),
},
}
}
// minutes 分钟数转 Duration。
func minutes(n int) time.Duration {
if n <= 0 {
n = 1
}
return time.Duration(n) * time.Minute
}
// bodyLimitFn 构造按路径分类的请求体上限函数(M3):
// - /admin/*1MiB(管理接口均为小 JSON/表单);
// - /share/text|metadata|select、/setup1MiB(文本内容本身限 222KB);
// - 其余(上传类):max_file_size0=回落 uploadSize,再 0=64MiB+ 2MiB 表单开销。
func bodyLimitFn(cfg *config.Config) func(c *gin.Context) int64 {
const adminLimit = int64(1) << 20
const textLimit = int64(1) << 20
const formOverhead = int64(2) << 20
const fallbackUpload = int64(64) << 20
return func(c *gin.Context) int64 {
p := c.Request.URL.Path
switch {
case p == "/setup" || strings.HasPrefix(p, "/admin/"):
return adminLimit
case p == "/share/text" || p == "/share/metadata" || p == "/share/select":
return textLimit
}
limit := cfg.MaxFileSize()
if limit <= 0 {
limit = cfg.UploadSize()
}
if limit <= 0 {
limit = fallbackUpload
}
// 分片/直传单请求体 ≤ 单片大小;share/file 的 multipart 有边界开销
return limit + formOverhead
}
}