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
+42
View File
@@ -0,0 +1,42 @@
// Package cache 提供统一缓存接口:FCB_REDIS_ADDR 未配置时自动降级为进程内存实现,
// 用于 IP 限流计数与热点配置缓存(需求 ② 的可选 Redis 增强)。
package cache
import (
"context"
"errors"
"time"
)
// ErrNotFound 表示键不存在。
var ErrNotFound = errors.New("cache: key 不存在")
// Cache 缓存统一接口。
type Cache interface {
// Get 读取字符串值;键不存在返回 ErrNotFound。
Get(ctx context.Context, key string) (string, error)
// Set 写入字符串值,ttl<=0 表示不过期。
Set(ctx context.Context, key, value string, ttl time.Duration) error
// Delete 删除键。
Delete(ctx context.Context, keys ...string) error
// Exists 判断键是否存在。
Exists(ctx context.Context, key string) (bool, error)
// Incr 原子自增;键不存在时从 0 开始并设置 ttl 窗口(限流固定窗口用)。
Incr(ctx context.Context, key string, ttl time.Duration) (int64, error)
// Close 释放底层资源(Redis 连接;内存实现为空操作)。
Close() error
}
// RedisOptions Redis 连接参数(addr 为空 → 内存实现;db 为 FCB_REDIS_DB 库号)。
type RedisOptions struct {
Addr string
DB int // 逻辑库号 0-15cluster 模式忽略)
}
// New 按配置构造缓存实现:redisAddr 为空 → 内存实现。
func New(ctx context.Context, opt RedisOptions) (Cache, error) {
if opt.Addr == "" {
return NewMemory(), nil
}
return NewRedis(ctx, opt.Addr, opt.DB)
}
+81
View File
@@ -0,0 +1,81 @@
package cache
import (
"context"
"sync"
"testing"
"time"
)
func TestMemoryCacheSetGet(t *testing.T) {
c := NewMemory()
defer c.Close()
ctx := context.Background()
if err := c.Set(ctx, "k1", "v1", 0); err != nil {
t.Fatalf("Set 失败: %v", err)
}
v, err := c.Get(ctx, "k1")
if err != nil || v != "v1" {
t.Fatalf("Get = (%q, %v)", v, err)
}
if _, err := c.Get(ctx, "missing"); err != ErrNotFound {
t.Fatalf("缺失键应返回 ErrNotFound: %v", err)
}
_ = c.Delete(ctx, "k1")
if _, err := c.Get(ctx, "k1"); err != ErrNotFound {
t.Fatal("删除后应不存在")
}
}
func TestMemoryCacheTTL(t *testing.T) {
c := NewMemory()
defer c.Close()
ctx := context.Background()
_ = c.Set(ctx, "ttl", "x", 50*time.Millisecond)
if ok, _ := c.Exists(ctx, "ttl"); !ok {
t.Fatal("TTL 内应存在")
}
time.Sleep(80 * time.Millisecond)
if _, err := c.Get(ctx, "ttl"); err != ErrNotFound {
t.Fatal("过期后应不存在")
}
}
func TestMemoryCacheIncrWindow(t *testing.T) {
c := NewMemory()
defer c.Close()
ctx := context.Background()
for i := int64(1); i <= 3; i++ {
n, err := c.Incr(ctx, "rl", time.Minute)
if err != nil || n != i {
t.Fatalf("Incr = (%d, %v), want (%d, nil)", n, err, i)
}
}
// 窗口过期后重新计数
_ = c.Set(ctx, "short", "seed", time.Millisecond)
time.Sleep(5 * time.Millisecond)
n, err := c.Incr(ctx, "short", time.Millisecond)
if err != nil || n != 1 {
t.Fatalf("过期窗口重置失败: (%d, %v)", n, err)
}
}
func TestMemoryCacheConcurrentIncr(t *testing.T) {
c := NewMemory()
defer c.Close()
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = c.Incr(ctx, "cnt", time.Minute)
}()
}
wg.Wait()
n, _ := c.Incr(ctx, "cnt", time.Minute)
if n != 51 {
t.Fatalf("并发计数丢失: %d != 51", n)
}
}
+159
View File
@@ -0,0 +1,159 @@
package cache
import (
"context"
"sync"
"time"
)
// memoryItem 内存缓存条目。
type memoryItem struct {
value string
expiresAt time.Time // 零值表示不过期
}
// MemoryCache 进程内存缓存实现(单机、无持久化)。
type MemoryCache struct {
mu sync.RWMutex
items map[string]memoryItem
done chan struct{}
}
// NewMemory 构造内存缓存,并启动后台过期清理。
func NewMemory() *MemoryCache {
m := &MemoryCache{
items: make(map[string]memoryItem),
done: make(chan struct{}),
}
go m.gcLoop()
return m
}
// gcLoop 每分钟清理一次过期键,避免长期运行内存膨胀。
func (m *MemoryCache) gcLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-m.done:
return
case now := <-ticker.C:
m.mu.Lock()
for k, item := range m.items {
if !item.expiresAt.IsZero() && now.After(item.expiresAt) {
delete(m.items, k)
}
}
m.mu.Unlock()
}
}
}
// Get 读取键值。
func (m *MemoryCache) Get(_ context.Context, key string) (string, error) {
m.mu.RLock()
item, ok := m.items[key]
m.mu.RUnlock()
if !ok {
return "", ErrNotFound
}
if !item.expiresAt.IsZero() && time.Now().After(item.expiresAt) {
return "", ErrNotFound
}
return item.value, nil
}
// Set 写入键值。
func (m *MemoryCache) Set(_ context.Context, key, value string, ttl time.Duration) error {
item := memoryItem{value: value}
if ttl > 0 {
item.expiresAt = time.Now().Add(ttl)
}
m.mu.Lock()
m.items[key] = item
m.mu.Unlock()
return nil
}
// Delete 删除键。
func (m *MemoryCache) Delete(_ context.Context, keys ...string) error {
m.mu.Lock()
for _, k := range keys {
delete(m.items, k)
}
m.mu.Unlock()
return nil
}
// Exists 判断键是否存在。
func (m *MemoryCache) Exists(_ context.Context, key string) (bool, error) {
_, err := m.Get(context.Background(), key)
return err == nil, nil
}
// Incr 原子自增;首次创建时记录窗口起点(以过期时间体现)。
func (m *MemoryCache) Incr(_ context.Context, key string, ttl time.Duration) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
item, ok := m.items[key]
if ok && !item.expiresAt.IsZero() && now.After(item.expiresAt) {
// 窗口已过期,重新计数
ok = false
}
var n int64
if !ok {
n = 1
newItem := memoryItem{value: "1"}
if ttl > 0 {
newItem.expiresAt = now.Add(ttl)
}
m.items[key] = newItem
return n, nil
}
// 解析现有值
for _, c := range item.value {
if c < '0' || c > '9' {
n = 0
break
}
n = n*10 + int64(c-'0')
}
n++
newItem := memoryItem{value: itoa(n), expiresAt: item.expiresAt}
m.items[key] = newItem
return n, nil
}
// Close 停止清理协程。
func (m *MemoryCache) Close() error {
select {
case <-m.done:
default:
close(m.done)
}
return nil
}
// itoa 简单整数转字符串,避免在锁内依赖 strconv 的额外开销(数值都很小)。
func itoa(n int64) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+117
View File
@@ -0,0 +1,117 @@
package cache
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// RedisCache 基于 Redis 的缓存实现(可选增强)。
type RedisCache struct {
client *redis.Client
}
// NewRedis 连接 Redis 并校验可用性。addr 支持两种形式:
// - host:port(纯地址,库号由 db 参数指定)
// - redis://[:password@]host:port[/db]URL 形式,URL 中的库号优先于 db 参数)
func NewRedis(ctx context.Context, addr string, db int) (*RedisCache, error) {
opts, err := buildRedisOptions(addr, db)
if err != nil {
return nil, err
}
client := redis.NewClient(opts)
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("cache: Redis 连接失败 %s: %w", addr, err)
}
return &RedisCache{client: client}, nil
}
// buildRedisOptions 构造 go-redis 连接选项(纯地址 / URL 形式统一入口)。
func buildRedisOptions(addr string, db int) (*redis.Options, error) {
opts := &redis.Options{
Addr: addr,
DB: db,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolSize: 32,
}
if strings.HasPrefix(addr, "redis://") || strings.HasPrefix(addr, "rediss://") {
u, err := redis.ParseURL(addr)
if err != nil {
return nil, fmt.Errorf("cache: Redis 地址解析失败 %s: %w", addr, err)
}
// URL 未显式携带库号(路径为空或 /)时用 db 参数;显式 /N 优先
if u.DB == 0 && !urlHasDBPath(addr) {
u.DB = db
}
u.DialTimeout = opts.DialTimeout
u.ReadTimeout = opts.ReadTimeout
u.WriteTimeout = opts.WriteTimeout
u.PoolSize = opts.PoolSize
opts = u
}
return opts, nil
}
// urlHasDBPath 判断 redis:// URL 是否显式携带了库号路径(如 /5)。
func urlHasDBPath(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
return strings.Trim(u.Path, "/") != ""
}
// Get 读取键值。
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrNotFound
}
return val, err
}
// Set 写入键值。
func (r *RedisCache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
return r.client.Set(ctx, key, value, ttl).Err()
}
// Delete 删除键。
func (r *RedisCache) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return r.client.Del(ctx, keys...).Err()
}
// Exists 判断键是否存在。
func (r *RedisCache) Exists(ctx context.Context, key string) (bool, error) {
n, err := r.client.Exists(ctx, key).Result()
return n > 0, err
}
// Incr 原子自增;首次创建时设置窗口 TTL。
// 使用 Lua 脚本保证 INCR+EXPIRE 原子性,避免多实例下窗口被反复重置。
func (r *RedisCache) Incr(ctx context.Context, key string, ttl time.Duration) (int64, error) {
var incrScript = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 and ARGV[1] ~= '0' then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return n
`)
ttlMs := int64(0)
if ttl > 0 {
ttlMs = ttl.Milliseconds()
}
return incrScript.Run(ctx, r.client, []string{key}, ttlMs).Int64()
}
// Close 关闭 Redis 连接。
func (r *RedisCache) Close() error { return r.client.Close() }
+70
View File
@@ -0,0 +1,70 @@
// redis_options_test.go — FCB_REDIS_DB / URL 库号解析单测。
package cache
import "testing"
func TestBuildRedisOptionsPlainAddr(t *testing.T) {
opts, err := buildRedisOptions("127.0.0.1:6379", 0)
if err != nil {
t.Fatalf("plain addr: %v", err)
}
if opts.DB != 0 {
t.Fatalf("默认库号应为 0, got %d", opts.DB)
}
opts, err = buildRedisOptions("127.0.0.1:6379", 5)
if err != nil {
t.Fatalf("plain addr db=5: %v", err)
}
if opts.Addr != "127.0.0.1:6379" || opts.DB != 5 {
t.Fatalf("host:port + db: got addr=%s db=%d", opts.Addr, opts.DB)
}
}
func TestBuildRedisOptionsURL(t *testing.T) {
cases := []struct {
name string
url string
dbParam int
wantDB int
wantPw string
}{
{"URL 无库号用参数", "redis://127.0.0.1:6379", 3, 3, ""},
{"URL 显式库号优先", "redis://127.0.0.1:6379/7", 3, 7, ""},
{"URL 带密码", "redis://:secretpw@127.0.0.1:6379/2", 0, 2, "secretpw"},
{"rediss 无库号用参数", "rediss://127.0.0.1:6379", 9, 9, ""},
{"URL 根路径视为无库号", "redis://127.0.0.1:6379/", 4, 4, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
opts, err := buildRedisOptions(tc.url, tc.dbParam)
if err != nil {
t.Fatalf("buildRedisOptions(%q): %v", tc.url, err)
}
if opts.DB != tc.wantDB {
t.Fatalf("db = %d, want %d", opts.DB, tc.wantDB)
}
if opts.Password != tc.wantPw {
t.Fatalf("password = %q, want %q", opts.Password, tc.wantPw)
}
if opts.Addr != "127.0.0.1:6379" {
t.Fatalf("addr = %q", opts.Addr)
}
})
}
}
func TestBuildRedisOptionsInvalidURL(t *testing.T) {
if _, err := buildRedisOptions("redis://[bad", 0); err == nil {
t.Fatal("非法 URL 应报错")
}
}
func TestURLHasDBPath(t *testing.T) {
if urlHasDBPath("redis://h:6379") || urlHasDBPath("redis://h:6379/") {
t.Fatal("无路径或根路径应视为 false")
}
if !urlHasDBPath("redis://h:6379/5") {
t.Fatal("/5 应视为 true")
}
}