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:
Vendored
+42
@@ -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-15(cluster 模式忽略)
|
||||
}
|
||||
|
||||
// New 按配置构造缓存实现:redisAddr 为空 → 内存实现。
|
||||
func New(ctx context.Context, opt RedisOptions) (Cache, error) {
|
||||
if opt.Addr == "" {
|
||||
return NewMemory(), nil
|
||||
}
|
||||
return NewRedis(ctx, opt.Addr, opt.DB)
|
||||
}
|
||||
Vendored
+81
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+159
@@ -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:])
|
||||
}
|
||||
Vendored
+117
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user