FileCodeBox Go 重写版 v2.5.6(安全审计修复版)

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 9686fe887a
173 changed files with 32455 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
// Package config 提供全局配置:默认值对齐参考实现 core/settings.py
// 支持 FCB_* 环境变量覆盖默认值,再由数据库 settings KV 做运行时覆盖。
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
// 会话有效期边界(与参考实现保持一致:天级、可配 1~365 天)。
const (
// AdminSessionExpireDefault 默认 7 天(L8:由 30 天缩短,降低 localStorage
// token 泄露后的暴露窗口;管理员可在 1~365 天内自行调整)
AdminSessionExpireDefault = 7 * 24 * 60 * 60
AdminSessionExpireMin = 24 * 60 * 60 // 最小 1 天
AdminSessionExpireMax = 365 * 24 * 60 * 60 // 最大 365 天
// DefaultSQLitePath SQLite 模式默认数据库文件路径(相对运行目录,自动创建 data/)。
DefaultSQLitePath = "./data/filecodebox.db"
)
// 数据库驱动常量(需求 ⑧:SQLite 默认、Postgres 可选)。
const (
DBDriverSQLite = "sqlite"
DBDriverPostgres = "postgres"
)
// DefaultLogoURL / DefaultFaviconURL 默认 Logo 与 favicon(需求 ⑤):
// v2 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png
// 经 Vite 产出 /assets/logo-*.svg 与 /assets/favicon-*.png)。此处留空,
// GET /api/v1/config 下发空值时前端 displayLogoUrl/displayFaviconUrl 回落到本地打包资源;
// 管理端仍可设置任意 URL 全站替换。
const DefaultLogoURL = ""
// DefaultFaviconURL favicon/备用 Logo 默认空串(语义见 DefaultLogoURL 注释)。
const DefaultFaviconURL = ""
// Config 运行时配置。Env 为 FCB_* 环境变量解析结果(进程级),
// KV 为数据库 settings 键值覆盖(可被管理端动态修改)。
type Config struct {
Env *EnvConfig
KV map[string]any
}
// EnvConfig 进程级环境变量配置,仅能通过环境变量修改。
type EnvConfig struct {
DBDriver string // FCB_DB_DRIVERsqlite|postgres,默认 sqlite(需求 ⑧)
DBDSN string // FCB_DB_DSNpostgres 必需;sqlite 为空时用 DefaultSQLitePath
RedisAddr string // FCB_REDIS_ADDR,可选;为空时缓存降级为内存实现
RedisDB int // FCB_REDIS_DBRedis 逻辑库号 0-15,默认 0(URL 形式地址以 URL 内库号优先)
Listen string // FCB_LISTEN,监听地址,默认 :8466
StorageEngine string // FCB_STORAGE_ENGINElocal|s3|webdav,默认 local
TrustedProxies []string // FCB_TRUSTED_PROXIES,逗号分隔的可信代理 CIDR
}
// defaults 返回与参考实现 core/settings.py DEFAULT_CONFIG 对齐的默认配置。
func defaults() map[string]any {
return map[string]any{
// 存储引擎与路径
"file_storage": "local",
"storage_path": "",
"storageLimit": 0,
// v3:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
"storage_engine": "",
"site_domain": "",
// 站点信息
"name": "文件快传",
"site_name": "文件快传", // 新增:管理端可自定义
"description": "开箱即用的文件快传系统",
"notify_title": "系统通知",
"notify_content": "欢迎使用文件快传,拖拽或粘贴即可分享文本与文件。",
"page_explain": "请勿上传或分享违法内容。根据《中华人民共和国网络安全法》、《中华人民共和国刑法》、《中华人民共和国治安管理处罚法》等相关规定。 传播或存储违法、违规内容,会受到相关处罚,严重者将承担刑事责任。本站坚决配合相关部门,确保网络内容的安全,和谐,打造绿色网络环境。",
"keywords": "文件快传, 文件分享, 匿名口令分享文本, 文件",
// 需求 ⑤:默认 Logo 与 favicon(空 = 前端使用打包的本地资源)
"logo_url": DefaultLogoURL,
"favicon_url": DefaultFaviconURL,
// 需求 ①:背景图(v2 新增 background_urlbackground 为参考实现既有键,保留兼容)
"background": "",
"background_url": "",
// 需求 ②:页脚自定义内容与备案号
"footer_text": "",
"footer_beian": "",
// 需求 ③:系统通知(notify_enabled 新增开关,title/content 沿用参考语义)
"notify_enabled": 1,
// 需求 ④:保存策略(次数上限新增;时间上限沿用 max_save_seconds
"max_save_count": 0,
// 需求 ⑩:存储策略-单文件上限(0=回落 uploadSize,避免与参考键冲突)
"max_file_size": 0,
// 本地存储
"local_storage_path": "",
// S3 引擎
"s3_access_key_id": "",
"s3_secret_access_key": "",
"s3_bucket_name": "",
"s3_endpoint_url": "",
"s3_region_name": "auto",
"s3_signature_version": "s3v4",
"s3_hostname": "",
"s3_addressing_style": "auto",
"s3_proxy": 0,
"aws_session_token": "",
// WebDAV 引擎
"webdav_url": "",
"webdav_username": "",
"webdav_password": "",
"webdav_root_path": "filebox_storage",
"webdav_proxy": 0,
// 安全
"admin_token": "", // 管理员密码哈希;为空表示未初始化
"jwt_secret": "",
"adminSessionExpire": AdminSessionExpireDefault,
// 上传与分享策略
"openUpload": 1,
"uploadSize": 1024 * 1024 * 10,
"allowed_file_types": []string{"*"},
"expireStyle": []string{"day", "hour", "minute", "forever", "count"},
"code_generate_type": "secret",
"uploadMinute": 1,
"uploadCount": 10,
"errorMinute": 1,
"errorCount": 10,
"loginCount": 5,
"loginMinute": 15,
"max_save_seconds": 0,
"enableChunk": 0,
// 界面
"opacity": 0.9,
"showAdminAddr": 0,
"robotsText": "User-agent: *\nDisallow: /",
"serverWorkers": 1,
"serverHost": "0.0.0.0",
"serverPort": 8466,
}
}
// loadEnv 解析 FCB_* 环境变量;返回 nil 表示未设置任何必需项。
func loadEnv() (*EnvConfig, error) {
env := &EnvConfig{
DBDriver: strings.ToLower(strings.TrimSpace(os.Getenv("FCB_DB_DRIVER"))),
DBDSN: strings.TrimSpace(os.Getenv("FCB_DB_DSN")),
RedisAddr: strings.TrimSpace(os.Getenv("FCB_REDIS_ADDR")),
Listen: strings.TrimSpace(os.Getenv("FCB_LISTEN")),
StorageEngine: strings.TrimSpace(os.Getenv("FCB_STORAGE_ENGINE")),
}
// Redis 库号(FCB_REDIS_DB0-15;非法值忽略用默认 0)
if v := strings.TrimSpace(os.Getenv("FCB_REDIS_DB")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 15 {
env.RedisDB = n
}
}
if env.Listen == "" {
env.Listen = ":8466"
}
if env.StorageEngine == "" {
env.StorageEngine = "local"
}
switch env.StorageEngine {
case "local", "s3", "webdav":
default:
return nil, fmt.Errorf("FCB_STORAGE_ENGINE 无效值 %q,仅支持 local|s3|webdav", env.StorageEngine)
}
if raw := strings.TrimSpace(os.Getenv("FCB_TRUSTED_PROXIES")); raw != "" {
for _, item := range strings.Split(raw, ",") {
if item = strings.TrimSpace(item); item != "" {
env.TrustedProxies = append(env.TrustedProxies, item)
}
}
}
return env, nil
}
// New 从环境变量构造配置;KV 覆盖先为空。
// 需求 ⑧:FCB_DB_DRIVER 默认 sqlite(零依赖);postgres 必须提供 FCB_DB_DSN。
func New() (*Config, error) {
env, err := loadEnv()
if err != nil {
return nil, err
}
switch env.DBDriver {
case "", DBDriverSQLite:
env.DBDriver = DBDriverSQLite
// sqlite 模式 DSN 可为空:数据库层回退到 DefaultSQLitePath
case DBDriverPostgres:
if env.DBDSN == "" {
return nil, fmt.Errorf("FCB_DB_DRIVER=postgres 时必须提供 FCB_DB_DSNPostgres 连接串)")
}
default:
return nil, fmt.Errorf("FCB_DB_DRIVER 无效值 %q,仅支持 sqlite|postgres", env.DBDriver)
}
return &Config{Env: env, KV: map[string]any{}}, nil
}
// ApplyKV 用数据库 settings KV 覆盖运行时配置(内部键以 _ 开头的不允许覆盖)。
func (c *Config) ApplyKV(kv map[string]any) {
for k, v := range kv {
if strings.HasPrefix(k, "_") {
continue
}
c.KV[k] = v
}
}
// Get 按 键读取:KV 覆盖 > 默认值;找不到返回零值与 false。
func (c *Config) Get(key string) (any, bool) {
if v, ok := c.KV[key]; ok {
return v, true
}
v, ok := defaults()[key]
return v, ok
}
// GetString 取字符串配置。
func (c *Config) GetString(key string) string {
v, ok := c.Get(key)
if !ok || v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
// GetInt 取整型配置,兼容 JSON 数字(float64)与字符串。
func (c *Config) GetInt(key string) int {
n, _ := c.getInt64(key)
return int(n)
}
// GetInt64 取长整型配置。
func (c *Config) GetInt64(key string) int64 {
n, _ := c.getInt64(key)
return n
}
func (c *Config) getInt64(key string) (int64, bool) {
v, ok := c.Get(key)
if !ok || v == nil {
return 0, false
}
switch n := v.(type) {
case int:
return int64(n), true
case int64:
return n, true
case float64:
return int64(n), true
case string:
if n, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil {
return n, true
}
}
return 0, false
}
// GetBool 取布尔配置,兼容 1/0、"true"/"false"/"on"/"yes"。
func (c *Config) GetBool(key string) bool {
v, ok := c.Get(key)
if !ok || v == nil {
return false
}
switch b := v.(type) {
case bool:
return b
case int:
return b != 0
case float64:
return b != 0
case string:
switch strings.ToLower(strings.TrimSpace(b)) {
case "1", "true", "on", "yes":
return true
}
}
return false
}
// GetStringSlice 取字符串切片配置。
// SiteDomain 站点对外域名(v3.1):空=分享链接用当前访问地址。
func (c *Config) SiteDomain() string {
return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/")
}
func (c *Config) GetStringSlice(key string) []string {
v, ok := c.Get(key)
if !ok || v == nil {
return nil
}
switch s := v.(type) {
case []string:
return s
case []any:
out := make([]string, 0, len(s))
for _, item := range s {
if item == nil {
continue
}
out = append(out, fmt.Sprintf("%v", item))
}
return out
case string:
var out []string
for _, item := range strings.Split(s, ",") {
if item = strings.TrimSpace(item); item != "" {
out = append(out, item)
}
}
return out
}
return nil
}
// —— 常用字段的便捷访问(与参考 settings.xxx 对齐)——
// SiteName 站点名称。
func (c *Config) SiteName() string {
if v := c.GetString("site_name"); v != "" {
return v
}
return c.GetString("name")
}
// LogoURL 页面 Logo。
func (c *Config) LogoURL() string { return c.GetString("logo_url") }
// FaviconURL favicon 地址。
func (c *Config) FaviconURL() string { return c.GetString("favicon_url") }
// OpenUpload 是否允许游客上传。
func (c *Config) OpenUpload() bool { return c.GetBool("openUpload") }
// UploadSize 单文件大小上限(字节)。
func (c *Config) UploadSize() int64 { return c.GetInt64("uploadSize") }
// AllowedFileTypes 允许的文件类型列表("*" 表示不限制)。
func (c *Config) AllowedFileTypes() []string { return c.GetStringSlice("allowed_file_types") }
// ExpireStyle 允许的过期方式。
func (c *Config) ExpireStyle() []string { return c.GetStringSlice("expireStyle") }
// EnableChunk 是否启用分片上传。
func (c *Config) EnableChunk() bool { return c.GetBool("enableChunk") }
// MaxSaveSeconds 最长保存秒数,0 表示不限制。
func (c *Config) MaxSaveSeconds() int64 { return c.GetInt64("max_save_seconds") }
// MaxSaveCount 单次分享最大可取次数上限(需求 ④),0 表示不限制。
func (c *Config) MaxSaveCount() int { return c.GetInt("max_save_count") }
// MaxFileSize 存储策略-单文件上限(需求 ⑩);0 表示回落 uploadSize。
func (c *Config) MaxFileSize() int64 {
if n := c.GetInt64("max_file_size"); n > 0 {
return n
}
return c.UploadSize()
}
// FooterText 页脚自定义内容(需求 ②)。
func (c *Config) FooterText() string { return c.GetString("footer_text") }
// FooterBeian 备案号(需求 ②)。
func (c *Config) FooterBeian() string { return c.GetString("footer_beian") }
// BackgroundURL 背景图地址(需求 ①);空表示使用主题默认。
func (c *Config) BackgroundURL() string {
if v := c.GetString("background_url"); v != "" {
return v
}
return c.GetString("background")
}
// NotifyEnabled 系统通知开关(需求 ③):默认开启。
func (c *Config) NotifyEnabled() bool {
if v, ok := c.Get("notify_enabled"); ok && v != nil {
return c.GetBool("notify_enabled")
}
return true
}
// SQLitePath 数据库文件路径:sqlite 模式下 DSN 为空时回退默认路径(需求 ⑧)。
func (c *Config) SQLitePath() string {
if c.Env.DBDriver != DBDriverSQLite {
return ""
}
if c.Env.DBDSN != "" {
return c.Env.DBDSN
}
return DefaultSQLitePath
}
// AdminSessionExpireSeconds 管理员会话有效期(秒),
// 参考 apps/admin/dependencies.py 的 get_admin_session_expire_seconds。
func (c *Config) AdminSessionExpireSeconds() int {
n := c.GetInt("adminSessionExpire")
if n < AdminSessionExpireMin || n > AdminSessionExpireMax || n%AdminSessionExpireMin != 0 {
return AdminSessionExpireDefault
}
return n
}
// Engine 当前存储引擎。
// Engine 返回当前存储引擎名:KV storage_engine 优先(v3 运行时可改),
// 空(未设置/历史数据)回落启动值 Env.StorageEngineenv 校验过的 local|s3|webdav)。
// 枚举校验内联(避免 config→storage 反向依赖)。
func (c *Config) Engine() string {
if v, ok := c.Get(KeyStorageEngine); ok {
if s, isStr := v.(string); isStr {
switch s {
case "local", "s3", "webdav":
return s
}
}
}
return c.Env.StorageEngine
}
+160
View File
@@ -0,0 +1,160 @@
package config
import (
"testing"
)
func TestNewDefaultsToSQLite(t *testing.T) {
t.Setenv("FCB_DB_DRIVER", "")
t.Setenv("FCB_DB_DSN", "")
t.Setenv("FCB_REDIS_ADDR", "")
t.Setenv("FCB_LISTEN", "")
t.Setenv("FCB_STORAGE_ENGINE", "")
c, err := New()
if err != nil {
t.Fatalf("默认(无 DSN)应可构造: %v", err)
}
if c.Env.DBDriver != DBDriverSQLite {
t.Errorf("默认驱动应为 sqlite,实际 %s", c.Env.DBDriver)
}
if c.SQLitePath() != DefaultSQLitePath {
t.Errorf("SQLite 默认路径 = %s", c.SQLitePath())
}
if c.Env.Listen != ":8466" {
t.Errorf("默认监听地址错误: %s", c.Env.Listen)
}
if c.Env.StorageEngine != "local" {
t.Errorf("默认存储引擎错误: %s", c.Env.StorageEngine)
}
if c.Env.RedisAddr != "" {
t.Errorf("RedisAddr 应为空: %s", c.Env.RedisAddr)
}
}
func TestNewPostgresRequiresDSN(t *testing.T) {
t.Setenv("FCB_DB_DRIVER", "postgres")
t.Setenv("FCB_DB_DSN", "")
if _, err := New(); err == nil {
t.Fatal("postgres 模式缺少 FCB_DB_DSN 应报错")
}
t.Setenv("FCB_DB_DSN", "postgres://user:pass@localhost:5432/fcb")
c, err := New()
if err != nil {
t.Fatalf("postgres + DSN 应可构造: %v", err)
}
if c.Env.DBDriver != DBDriverPostgres {
t.Errorf("驱动应为 postgres,实际 %s", c.Env.DBDriver)
}
if c.SQLitePath() != "" {
t.Errorf("postgres 模式 SQLitePath 应为空: %s", c.SQLitePath())
}
}
func TestNewInvalidDriver(t *testing.T) {
t.Setenv("FCB_DB_DRIVER", "mysql")
t.Setenv("FCB_DB_DSN", "x")
if _, err := New(); err == nil {
t.Fatal("非法驱动应报错")
}
}
func TestNewInvalidEngine(t *testing.T) {
t.Setenv("FCB_DB_DSN", "postgres://x")
t.Setenv("FCB_STORAGE_ENGINE", "onedrive")
if _, err := New(); err == nil {
t.Fatal("非法引擎应报错")
}
}
func TestEnvOverridesAndDefaults(t *testing.T) {
t.Setenv("FCB_DB_DSN", "postgres://x")
t.Setenv("FCB_LISTEN", ":9999")
t.Setenv("FCB_STORAGE_ENGINE", "webdav")
c, err := New()
if err != nil {
t.Fatalf("New 失败: %v", err)
}
if c.Env.Listen != ":9999" || c.Env.StorageEngine != "webdav" {
t.Fatalf("env 覆盖失败: %+v", c.Env)
}
// 默认值对齐参考 DEFAULT_CONFIG
if got := c.GetInt("uploadSize"); got != 1024*1024*10 {
t.Errorf("uploadSize 默认值 = %d", got)
}
if got := c.GetInt("errorCount"); got != 10 {
t.Errorf("errorCount 默认值 = %d", got)
}
if got := c.GetInt("loginCount"); got != 5 {
t.Errorf("loginCount 默认值 = %d", got)
}
if got := c.GetInt("loginMinute"); got != 15 {
t.Errorf("loginMinute 默认值 = %d", got)
}
if got := c.GetBool("openUpload"); !got {
t.Error("openUpload 默认应为开启")
}
if c.EnableChunk() {
t.Error("enableChunk 默认应关闭")
}
// 新增字段(需求 ①)
if c.LogoURL() != DefaultLogoURL {
t.Errorf("logo_url 默认值 = %s", c.LogoURL())
}
if c.FaviconURL() != DefaultFaviconURL {
t.Errorf("favicon_url 默认值 = %s", c.FaviconURL())
}
if c.SiteName() == "" {
t.Error("site_name 默认值不应为空")
}
// 过期方式与文件类型
if len(c.ExpireStyle()) != 5 {
t.Errorf("expireStyle 默认值 = %v", c.ExpireStyle())
}
if len(c.AllowedFileTypes()) != 1 || c.AllowedFileTypes()[0] != "*" {
t.Errorf("allowed_file_types 默认值 = %v", c.AllowedFileTypes())
}
}
func TestKVOverridesEnvAndDefaults(t *testing.T) {
t.Setenv("FCB_DB_DSN", "postgres://x")
c, _ := New()
c.ApplyKV(map[string]any{
"uploadSize": 1024,
"openUpload": 0,
"site_name": "我的快递柜",
"logo_url": "https://example.com/logo.svg",
"internalKey": "x", // 非下划线开头允许;下划线开头被拒
"_secret": "no",
})
if got := c.GetInt("uploadSize"); got != 1024 {
t.Errorf("KV 覆盖 uploadSize 失败: %d", got)
}
if c.OpenUpload() {
t.Error("KV 覆盖 openUpload 失败")
}
if c.SiteName() != "我的快递柜" {
t.Errorf("site_name KV 覆盖失败: %s", c.SiteName())
}
if c.LogoURL() != "https://example.com/logo.svg" {
t.Errorf("logo_url KV 覆盖失败: %s", c.LogoURL())
}
if _, ok := c.Get("_secret"); ok {
t.Error("下划线内部键不应可通过 ApplyKV 覆盖")
}
}
func TestAdminSessionExpireClamp(t *testing.T) {
t.Setenv("FCB_DB_DSN", "postgres://x")
c, _ := New()
if got := c.AdminSessionExpireSeconds(); got != AdminSessionExpireDefault {
t.Errorf("默认会话有效期 = %d", got)
}
c.ApplyKV(map[string]any{"adminSessionExpire": 7 * 24 * 60 * 60})
if got := c.AdminSessionExpireSeconds(); got != 7*24*60*60 {
t.Errorf("7 天会话有效期 = %d", got)
}
c.ApplyKV(map[string]any{"adminSessionExpire": 3600}) // 非整天,回落默认
if got := c.AdminSessionExpireSeconds(); got != AdminSessionExpireDefault {
t.Errorf("非法值应回落默认 = %d", got)
}
}
+97
View File
@@ -0,0 +1,97 @@
// Package config — schema.go 定义 v2 新增配置键(KVschema
// 键名常量、类型、默认值与取值边界。管理与 API 层(t2)按下表读写与校验,
// 文档(t4)按本表生成说明。键名除参考实现既有 camelCase 键外,
// v2 新增键统一 snake_case。
package config
// —— v2 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
// 命名规则:v2 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
const (
// 需求 ①:背景图
KeyBackground = "background" // 参考实现既有键(v1 兼容保留)
KeyBackgroundURL = "background_url" // v2 新增:背景图 URL 或上传后的访问地址(空=默认主题)
// 需求 ②:页脚
KeyFooterText = "footer_text" // v2 新增:页脚自定义内容(纯文本或受控 HTML 片段)
KeyFooterBeian = "footer_beian" // v2 新增:备案号(如 京ICP备2024xxxxxx号-1
// 需求 ③:系统通知
KeyNotifyEnabled = "notify_enabled" // v2 新增:通知开关,1 开启 / 0 关闭
KeyNotifyTitle = "notify_title" // 既有键:通知标题
KeyNotifyContent = "notify_content" // 既有键:通知内容(允许 <a> 等受控 HTML
// 需求 ④:保存策略(上传页动态读取并在范围内选择)
KeyMaxSaveSeconds = "max_save_seconds" // 既有键:最长保存秒数,0=不限制(仅受默认 7 天兜底)
KeyMaxSaveCount = "max_save_count" // v2 新增:单次分享最大可取(保存)次数上限,0=不限制
KeyExpireStyle = "expireStyle" // 既有键:允许的过期方式白名单
// 需求 ④:上传频率限制(既有键,对齐参考 ip_limit["upload"]
KeyUploadCount = "uploadCount" // 窗口内允许上传次数
KeyUploadMinute = "uploadMinute" // 频率窗口(分钟)
// 需求 ④⑩:存储策略(最大文件大小/允许类型/总容量)
KeyUploadSize = "uploadSize" // 既有键:单文件上限(字节),参考实现语义
KeyMaxFileSize = "max_file_size" // v2 新增:存储策略-单文件上限(字节),0=回落 uploadSize
KeyAllowedTypes = "allowed_file_types" // 既有键:允许类型白名单("*" 不限制)
KeyStorageLimit = "storageLimit" // 既有键:站点总容量(字节),0=不限制
KeyOpenUpload = "openUpload" // 既有键:游客上传开关
// v3:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav
KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址)
)
// —— 取值边界(管理端保存与 API 校验用)——
const (
// 保存时间上限:最长 365 天,0 表示不限制。
MaxSaveSecondsMax = 365 * 24 * 60 * 60
// 保存次数上限:最长 100000 次,0 表示不限制。
MaxSaveCountMax = 100000
// 单文件大小上限:最长 10 GiB0 表示回落 uploadSize。
MaxFileSizeMax = 10 * 1024 * 1024 * 1024
// 背景图 URL 最大长度(含 data: 之外的普通 http(s) URL)。
BackgroundURLMaxLen = 2048
// 页脚自定义内容最大长度。
FooterTextMaxLen = 2000
// 备案号最大长度。
FooterBeianMaxLen = 128
// 通知标题/内容最大长度。
NotifyTitleMaxLen = 128
NotifyContentMaxLen = 2000
)
// KVSchemaEntry 配置键元数据:类型 / 默认值 / 说明,供管理端 UI 与文档生成。
type KVSchemaEntry struct {
Key string // KV 键名
Type string // string | int | int64 | bool | []string
Default any // 默认值(与 defaults() 保持一致,测试保证同步)
Min int64 // 数值键最小值(字符串键为长度下界)
Max int64 // 数值键最大值(字符串键为长度上界;-1 不限制)
Description string // 中文说明
}
// KVSchema v2 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
// 注意:Default 与 config defaults() 逐一对应(schema_test 保证)。
func KVSchema() []KVSchemaEntry {
return []KVSchemaEntry{
// —— 需求 ① 背景图 ——
{KeyBackgroundURL, "string", "", 0, BackgroundURLMaxLen, "背景图 URL 或上传后地址(空=主题默认)"},
// —— 需求 ② 页脚 ——
{KeyFooterText, "string", "", 0, FooterTextMaxLen, "页脚自定义内容(纯文本或受控 HTML 片段)"},
{KeyFooterBeian, "string", "", 0, FooterBeianMaxLen, "备案号,展示于页脚"},
// —— 需求 ③ 系统通知 ——
{KeyNotifyEnabled, "int", 1, 0, 1, "系统通知开关:1 右上角悬浮窗展示 / 0 关闭"},
{KeyNotifyTitle, "string", "系统通知", 0, NotifyTitleMaxLen, "通知标题"},
{KeyNotifyContent, "string", "欢迎使用文件快传,拖拽或粘贴即可分享文本与文件。", 0, NotifyContentMaxLen, "通知内容(允许 <a> 等受控 HTML"},
// —— 需求 ④ 保存策略 ——
{KeyMaxSaveSeconds, "int64", int64(0), 0, MaxSaveSecondsMax, "最长保存秒数上限,0=不限制(默认 7 天兜底)"},
{KeyMaxSaveCount, "int", 0, 0, MaxSaveCountMax, "单次分享最大可取次数上限,0=不限制"},
{KeyExpireStyle, "[]string", []string{"day", "hour", "minute", "forever", "count"}, -1, -1, "上传页可选过期方式白名单"},
// —— 需求 ④ 上传频率限制(既有键对齐参考)——
{KeyUploadCount, "int", 10, 1, 10000, "频率窗口内允许的上传次数"},
{KeyUploadMinute, "int", 1, 1, 1440, "上传频率窗口(分钟)"},
// —— 需求 ④⑩ 存储策略 ——
{KeyMaxFileSize, "int64", int64(0), 0, MaxFileSizeMax, "存储策略-单文件上限(字节),0=回落 uploadSize"},
{KeyUploadSize, "int64", int64(1024 * 1024 * 10), 1024, MaxFileSizeMax, "单文件上限(字节),参考实现语义"},
{KeyAllowedTypes, "[]string", []string{"*"}, -1, -1, "允许上传类型白名单(\"*\" 不限制)"},
{KeyStorageLimit, "int64", int64(0), 0, -1, "站点总容量(字节),0=不限制"},
{KeyOpenUpload, "int", 1, 0, 1, "游客上传开关:1 开 / 0 需管理员登录"},
// —— v3 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
{KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE"},
{KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"},
}
}
+91
View File
@@ -0,0 +1,91 @@
// schema 同步测试:保证 config.KVSchema() 的默认值/键集合与 defaults() 完全一致,
// 与 settings 包 re-export 的键名常量同源。新增键时任何一处漏改都会在此失败。
package config
import (
"encoding/json"
"testing"
)
// TestKVSchemaDefaultsMatchDefaults KVSchema 的 Default 必须 === defaults() 中同名键。
func TestKVSchemaDefaultsMatchDefaults(t *testing.T) {
def := defaults()
for _, e := range KVSchema() {
want, ok := def[e.Key]
if !ok {
t.Fatalf("schema 键 %q 缺少 defaults() 默认值", e.Key)
}
// 类型规范化比较(JSON 序列化可比较 []string / int / float
a, _ := json.Marshal(e.Default)
b, _ := json.Marshal(want)
if string(a) != string(b) {
t.Fatalf("键 %q 默认值不一致: schema=%s defaults=%s", e.Key, a, b)
}
}
}
// TestKVSchemaNoDuplicates 键名不得重复。
func TestKVSchemaNoDuplicates(t *testing.T) {
seen := map[string]bool{}
for _, e := range KVSchema() {
if seen[e.Key] {
t.Fatalf("schema 键 %q 重复定义", e.Key)
}
seen[e.Key] = true
}
}
// TestV2NewKeysPresent v2 新增键必须在 schema 与 defaults 中同时存在。
func TestV2NewKeysPresent(t *testing.T) {
def := defaults()
newKeys := []string{
KeyBackgroundURL, KeyFooterText, KeyFooterBeian,
KeyNotifyEnabled, KeyMaxSaveCount, KeyMaxFileSize,
}
for _, k := range newKeys {
if _, ok := def[k]; !ok {
t.Fatalf("v2 新键 %q 缺少默认值", k)
}
}
}
// TestV2AccessorDefaults v2 便捷访问器默认语义。
func TestV2AccessorDefaults(t *testing.T) {
t.Setenv("FCB_DB_DRIVER", "sqlite")
t.Setenv("FCB_DB_DSN", "")
c, err := New()
if err != nil {
t.Fatalf("New: %v", err)
}
// 背景图:background_url 与 background 均空 → 空
if c.BackgroundURL() != "" {
t.Fatalf("背景图默认应为空: %q", c.BackgroundURL())
}
// legacy background 键兜底
c.ApplyKV(map[string]any{KeyBackground: "/legacy/bg.jpg"})
if c.BackgroundURL() != "/legacy/bg.jpg" {
t.Fatalf("legacy background 应回落生效: %q", c.BackgroundURL())
}
// max_file_size > 0 时优先于 uploadSize
c.ApplyKV(map[string]any{KeyMaxFileSize: int64(1024), KeyUploadSize: int64(2048)})
if c.MaxFileSize() != 1024 {
t.Fatalf("max_file_size 应优先: %d", c.MaxFileSize())
}
// max_file_size = 0 回落 uploadSize
c.ApplyKV(map[string]any{KeyMaxFileSize: int64(0)})
if c.MaxFileSize() != 2048 {
t.Fatalf("max_file_size=0 应回落 uploadSize: %d", c.MaxFileSize())
}
// 通知默认开启
if !c.NotifyEnabled() {
t.Fatal("notify_enabled 默认应开启")
}
// 保存次数上限默认不限制
if c.MaxSaveCount() != 0 {
t.Fatalf("max_save_count 默认应 0: %d", c.MaxSaveCount())
}
// 页脚默认空
if c.FooterText() != "" || c.FooterBeian() != "" {
t.Fatal("页脚默认应为空")
}
}