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 全绿;二进制端到端冒烟通过
450 lines
14 KiB
Go
450 lines
14 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// 每次读写使用的缓冲大小:256KB,对齐参考实现 SystemFileStorage.chunk_size。
|
|
const localChunkSize = 256 * 1024
|
|
|
|
// LocalStorage 本地文件系统引擎。
|
|
//
|
|
// 相比参考实现(SystemFileStorage)的改进:
|
|
// - 双重路径防护:清洗相对路径 + 根目录前缀校验 + 符号链接逃逸校验;
|
|
// - 全部落盘走「临时文件 + fsync + 原子重命名」,断电/中断不产生半截文件;
|
|
// - 下载使用 io.NewSectionReader 支持任意 Range,无需整文件读入内存。
|
|
type LocalStorage struct {
|
|
// root 存储根目录(绝对路径)。
|
|
root string
|
|
// rootReal 经符号链接解析后的真实根目录,用于逃逸校验。
|
|
rootReal string
|
|
}
|
|
|
|
// NewLocalStorage 构造本地引擎。root 为空时使用系统临时目录下的 filecodebox_storage。
|
|
func NewLocalStorage(root string) (*LocalStorage, error) {
|
|
if strings.TrimSpace(root) == "" {
|
|
root = filepath.Join(os.TempDir(), "filecodebox_storage")
|
|
}
|
|
abs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage/local: 解析根目录失败: %w", err)
|
|
}
|
|
if err := os.MkdirAll(abs, 0o755); err != nil {
|
|
return nil, fmt.Errorf("storage/local: 创建根目录失败: %w", err)
|
|
}
|
|
real := abs
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
real = resolved
|
|
}
|
|
return &LocalStorage{root: abs, rootReal: real}, nil
|
|
}
|
|
|
|
func init() {
|
|
RegisterEngine("local", func(ctx context.Context) (Storage, error) {
|
|
return NewLocalStorage(engineOptions.Local.Root)
|
|
})
|
|
}
|
|
|
|
// withinRoot 判断路径 p 是否位于 root 内(含 root 本身)。
|
|
func withinRoot(p, root string) bool {
|
|
p = filepath.Clean(p)
|
|
root = filepath.Clean(root)
|
|
if p == root {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(p, root+string(os.PathSeparator))
|
|
}
|
|
|
|
// absPath 将存储侧相对路径解析为根目录内的绝对路径。
|
|
// 任何路径穿越或符号链接逃逸都会返回 ErrInvalidPath。
|
|
func (l *LocalStorage) absPath(savePath string) (string, error) {
|
|
cleaned, ok := SanitizePath(savePath)
|
|
if !ok {
|
|
return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath)
|
|
}
|
|
full := filepath.Join(l.root, filepath.FromSlash(cleaned))
|
|
if !withinRoot(full, l.root) {
|
|
return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath)
|
|
}
|
|
// 符号链接逃逸校验:文件已存在时解析真实路径;不存在时校验最深已存在的父目录。
|
|
if real, err := filepath.EvalSymlinks(full); err == nil {
|
|
if !withinRoot(real, l.rootReal) {
|
|
return "", fmt.Errorf("%w: 符号链接逃逸 %q", ErrInvalidPath, savePath)
|
|
}
|
|
} else {
|
|
dir := filepath.Dir(full)
|
|
if realDir, err := filepath.EvalSymlinks(dir); err == nil && !withinRoot(realDir, l.rootReal) {
|
|
return "", fmt.Errorf("%w: 符号链接逃逸 %q", ErrInvalidPath, savePath)
|
|
}
|
|
}
|
|
return full, nil
|
|
}
|
|
|
|
// SaveFile 流式保存:256KB 分块读取写入临时文件,fsync 后原子重命名。
|
|
func (l *LocalStorage) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
|
|
full, err := l.absPath(savePath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
src := &countingReader{r: r}
|
|
if err := writeFileAtomic(full, src); err != nil {
|
|
return src.count(), err
|
|
}
|
|
return src.count(), nil
|
|
}
|
|
|
|
// DeleteFile 删除文件;文件不存在时静默成功(对齐契约)。
|
|
func (l *LocalStorage) DeleteFile(ctx context.Context, savePath string) error {
|
|
full, err := l.absPath(savePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("storage/local: 删除失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Open 打开文件下载流;rng 非 nil 时用 SectionReader 实现 Range 语义。
|
|
func (l *LocalStorage) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
|
|
full, err := l.absPath(savePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.Open(full)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("storage/local: 打开文件失败: %w", err)
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("storage/local: 获取文件信息失败: %w", err)
|
|
}
|
|
if info.IsDir() {
|
|
_ = f.Close()
|
|
return nil, ErrNotFound
|
|
}
|
|
size := info.Size()
|
|
start, end := int64(0), size-1
|
|
if rng != nil {
|
|
if rng.Start < 0 || (rng.End != -1 && rng.End < rng.Start) {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("%w: 非法 Range", ErrRangeNotSatisfiable)
|
|
}
|
|
if rng.Start >= size {
|
|
_ = f.Close()
|
|
return nil, ErrRangeNotSatisfiable
|
|
}
|
|
start = rng.Start
|
|
end = size - 1
|
|
if rng.End != -1 && rng.End < end {
|
|
end = rng.End
|
|
}
|
|
}
|
|
section := io.NewSectionReader(f, start, end-start+1)
|
|
dl := &Download{
|
|
ReadCloser: &fileSection{Reader: section, closer: f},
|
|
Start: start,
|
|
End: end,
|
|
Total: size,
|
|
Meta: FileMeta{
|
|
Size: size,
|
|
ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))),
|
|
AcceptRanges: true,
|
|
},
|
|
}
|
|
if end < 0 { // 空文件:End 语义上等于 -1(未知),Total=0 已表达大小
|
|
dl.End = -1
|
|
}
|
|
return dl, nil
|
|
}
|
|
|
|
// fileSection 组合 SectionReader 与文件关闭器。
|
|
type fileSection struct {
|
|
io.Reader
|
|
closer io.Closer
|
|
}
|
|
|
|
func (f *fileSection) Close() error { return f.closer.Close() }
|
|
|
|
// Stat 获取文件元信息。
|
|
func (l *LocalStorage) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
|
|
full, err := l.absPath(savePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := os.Stat(full)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("storage/local: Stat 失败: %w", err)
|
|
}
|
|
if info.IsDir() {
|
|
return nil, ErrNotFound
|
|
}
|
|
return &FileMeta{
|
|
Size: info.Size(),
|
|
ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))),
|
|
AcceptRanges: true,
|
|
}, nil
|
|
}
|
|
|
|
// SaveChunk 保存分片到 <父目录>/chunks/<uploadID>/<index>.part,原子写入。
|
|
func (l *LocalStorage) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
|
|
// 先校验目标路径合法性,分片目录随合法路径派生。
|
|
if _, err := l.absPath(savePath); err != nil {
|
|
return 0, err
|
|
}
|
|
chunkRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, chunkIndex))
|
|
if !ok {
|
|
return 0, fmt.Errorf("%w: 分片路径非法 %q", ErrInvalidPath, savePath)
|
|
}
|
|
full := filepath.Join(l.root, filepath.FromSlash(chunkRel))
|
|
if !withinRoot(full, l.root) {
|
|
return 0, fmt.Errorf("%w: 分片路径非法 %q", ErrInvalidPath, chunkRel)
|
|
}
|
|
src := &countingReader{r: r}
|
|
if err := writeFileAtomic(full, src); err != nil {
|
|
return src.count(), err
|
|
}
|
|
return src.count(), nil
|
|
}
|
|
|
|
// MergeChunks 按索引 0..total-1 有序合并分片:
|
|
// - 逐分片流式拷贝到临时输出(边拷贝边计算整文件与分片 SHA256);
|
|
// - verifyHash 非 nil 时校验分片哈希(空串跳过);
|
|
// - 全部通过后 fsync + 原子重命名,并清理分片临时目录。
|
|
func (l *LocalStorage) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
|
|
if total <= 0 {
|
|
return 0, "", fmt.Errorf("storage/local: 非法分片总数 %d", total)
|
|
}
|
|
full, err := l.absPath(savePath)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return 0, "", fmt.Errorf("storage/local: 创建目标目录失败: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(full), "."+filepath.Base(full)+".merging-*")
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("storage/local: 创建临时文件失败: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName) // 成功时已被重命名,删除静默失败
|
|
}()
|
|
|
|
totalHash := sha256.New()
|
|
buf := make([]byte, localChunkSize)
|
|
var size int64
|
|
for i := 0; i < total; i++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return 0, "", err
|
|
}
|
|
partRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, i))
|
|
if !ok {
|
|
return 0, "", fmt.Errorf("%w: 分片 %d 路径非法", ErrInvalidPath, i)
|
|
}
|
|
partPath := filepath.Join(l.root, filepath.FromSlash(partRel))
|
|
in, err := os.Open(partPath)
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("storage/local: 分片 %d 不存在: %w", i, err)
|
|
}
|
|
chunkHash := sha256.New()
|
|
n, err := io.CopyBuffer(io.MultiWriter(tmp, totalHash, chunkHash), in, buf)
|
|
_ = in.Close()
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("storage/local: 读取分片 %d 失败: %w", i, err)
|
|
}
|
|
if verifyHash != nil {
|
|
expected, err := verifyHash(i)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
if expected != "" && expected != hex.EncodeToString(chunkHash.Sum(nil)) {
|
|
return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, i, expected)
|
|
}
|
|
}
|
|
size += n
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
return 0, "", fmt.Errorf("storage/local: 落盘失败: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return 0, "", fmt.Errorf("storage/local: 关闭临时文件失败: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, full); err != nil {
|
|
return 0, "", fmt.Errorf("storage/local: 原子重命名失败: %w", err)
|
|
}
|
|
// 合并成功后清理分片临时目录(静默容错,不掩盖成功结果)。
|
|
_ = l.CleanChunks(ctx, uploadID, savePath)
|
|
return size, hex.EncodeToString(totalHash.Sum(nil)), nil
|
|
}
|
|
|
|
// CleanChunks 清理分片临时目录;不存在时静默成功,并尝试移除空 chunks 父目录。
|
|
func (l *LocalStorage) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
|
|
dirRel, ok := SanitizePath(chunkDirOf(savePath, uploadID))
|
|
if !ok {
|
|
return fmt.Errorf("%w: 分片目录非法 %q", ErrInvalidPath, savePath)
|
|
}
|
|
dir := filepath.Join(l.root, filepath.FromSlash(dirRel))
|
|
if !withinRoot(dir, l.root) {
|
|
return fmt.Errorf("%w: 分片目录非法 %q", ErrInvalidPath, dirRel)
|
|
}
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
return fmt.Errorf("storage/local: 清理分片目录失败: %w", err)
|
|
}
|
|
// 父级 chunks 目录为空则一并清理(对齐参考实现)。
|
|
chunksParent := filepath.Dir(dir)
|
|
if entries, err := os.ReadDir(chunksParent); err == nil && len(entries) == 0 {
|
|
_ = os.Remove(chunksParent)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// chunkDirOf 返回分片目录(去掉文件名部分):<父目录>/chunks/<uploadID>。
|
|
func chunkDirOf(savePath, uploadID string) string {
|
|
cd := ChunkDir(savePath, uploadID)
|
|
// ChunkDir 返回 "<dir>/chunks/<uploadID>/<name>",去掉末段文件名即目录。
|
|
if idx := strings.LastIndex(cd, "/"); idx > 0 {
|
|
return cd[:idx]
|
|
}
|
|
return cd
|
|
}
|
|
|
|
// FileExists 检查文件是否存在;非法路径按不存在处理(对齐参考实现)。
|
|
func (l *LocalStorage) FileExists(ctx context.Context, savePath string) (bool, error) {
|
|
full, err := l.absPath(savePath)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
info, err := os.Stat(full)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("storage/local: Stat 失败: %w", err)
|
|
}
|
|
return !info.IsDir(), nil
|
|
}
|
|
|
|
// HeadMeta 读取文件元信息与前 n 字节(本地引擎实现)。
|
|
func (l *LocalStorage) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
|
|
full, err := l.absPath(savePath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
f, err := os.Open(full)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil, ErrNotFound
|
|
}
|
|
return nil, nil, fmt.Errorf("storage/local: 打开文件失败: %w", err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("storage/local: Stat 失败: %w", err)
|
|
}
|
|
if info.IsDir() {
|
|
return nil, nil, ErrNotFound
|
|
}
|
|
head := make([]byte, headBytes)
|
|
n, _ := io.ReadFull(f, head)
|
|
return &FileMeta{
|
|
Size: info.Size(),
|
|
ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))),
|
|
AcceptRanges: true,
|
|
}, head[:n], nil
|
|
}
|
|
|
|
// PresignGetURL 本地引擎不支持直链。
|
|
func (l *LocalStorage) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
|
return "", ErrNotSupported
|
|
}
|
|
|
|
// PresignPutURL 本地引擎不支持直传。
|
|
func (l *LocalStorage) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
|
|
return "", ErrNotSupported
|
|
}
|
|
|
|
// HealthCheck 健康检查:根目录可写(写入并删除探针文件)。
|
|
func (l *LocalStorage) HealthCheck(ctx context.Context) error {
|
|
if err := os.MkdirAll(l.root, 0o755); err != nil {
|
|
return fmt.Errorf("%w: 本地存储根目录不可创建: %v", ErrUnavailable, err)
|
|
}
|
|
probe := filepath.Join(l.root, ".health-probe")
|
|
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
|
return fmt.Errorf("%w: 本地存储不可写: %v", ErrUnavailable, err)
|
|
}
|
|
_ = os.Remove(probe)
|
|
return nil
|
|
}
|
|
|
|
// writeFileAtomic 临时文件 + fsync + rename 的原子落盘。
|
|
func writeFileAtomic(dst string, src io.Reader) error {
|
|
dir := filepath.Dir(dst)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("storage/local: 创建目录失败: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(dir, "."+filepath.Base(dst)+".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("storage/local: 创建临时文件失败: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
cleanup := func() { _ = tmp.Close(); _ = os.Remove(tmpName) }
|
|
if _, err := io.CopyBuffer(tmp, src, make([]byte, localChunkSize)); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("storage/local: 写入失败: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("storage/local: fsync 失败: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("storage/local: 关闭临时文件失败: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, dst); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("storage/local: 原子重命名失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// countingReader 统计累计读取字节数(并发安全)。
|
|
type countingReader struct {
|
|
r io.Reader
|
|
n atomic.Int64
|
|
}
|
|
|
|
func (c *countingReader) Read(p []byte) (int, error) {
|
|
n, err := c.r.Read(p)
|
|
c.n.Add(int64(n))
|
|
return n, err
|
|
}
|
|
|
|
// count 返回累计字节数。
|
|
func (c *countingReader) count() int64 { return c.n.Load() }
|
|
|
|
// reset 归零计数(请求体重放时使用)。
|
|
func (c *countingReader) reset() { c.n.Store(0) }
|
|
|
|
// 接口编译期断言。
|
|
var _ Storage = (*LocalStorage)(nil)
|