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:
@@ -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_DRIVER,sqlite|postgres,默认 sqlite(需求 ⑧)
|
||||
DBDSN string // FCB_DB_DSN;postgres 必需;sqlite 为空时用 DefaultSQLitePath
|
||||
RedisAddr string // FCB_REDIS_ADDR,可选;为空时缓存降级为内存实现
|
||||
RedisDB int // FCB_REDIS_DB,Redis 逻辑库号 0-15,默认 0(URL 形式地址以 URL 内库号优先)
|
||||
Listen string // FCB_LISTEN,监听地址,默认 :8466
|
||||
StorageEngine string // FCB_STORAGE_ENGINE,local|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_url;background 为参考实现既有键,保留兼容)
|
||||
"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_DB,0-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_DSN(Postgres 连接串)")
|
||||
}
|
||||
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.StorageEngine(env 校验过的 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package config — schema.go 定义 v2 新增配置键(KV)schema:
|
||||
// 键名常量、类型、默认值与取值边界。管理与 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 GiB,0 表示回落 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],不带路径;空=分享链接用当前访问地址)"},
|
||||
}
|
||||
}
|
||||
@@ -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("页脚默认应为空")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user