Files
ONE/backend/internal/storage/r2.go
T
Sakurasan 34f0faedcc 文件上传:R2 对象存储 + 后台文件管理 + 编辑器/封面联动
存储抽象(internal/storage,新包)
- BlobStore 接口(Put / Open / Delete)+ 两个实现:R2(aws-sdk-go-v2 走
  R2 的 S3 兼容 API,path-style、region auto)与本地磁盘(DataDir/uploads,
  零配置兜底)。选择:Endpoint / S3Api / Bucket / AccessKey / SecretAccessKey
  齐全 → R2,缺任一项回落本地并在启动日志提示缺失的字段名(只报名字不报值)
- 端点 URL 里的路径段不交给 SDK:path-style 下它会被折进对象 key,
  导致「数据库 key」和「实际对象 key」对不上(直链 404,实测复现)。
  EndpointKeyPrefix 提取路径段给上传 handler 拼进 key,StripEndpointPath
  只取 scheme://host 给 SDK——数据库 / 存储端 / 直链三方一致

数据与 API
- files 表:id / key(唯一) / name / mime / size / sha256 / store(r2|local) /
  created_at;URL 不入库,按「store 来源 + PublicBase」响应时解析,
  切存储端不破坏存量链接
- POST /api/admin/files:multipart 多文件,单文件 ≤50MB(MaxBytesReader 64MB);
  类型白名单 = 图片(jpg/png/webp/gif/avif)+ 附件(pdf/zip/txt),
  扩展名 + http.DetectContentType 双重校验(实测拦截随机字节改名 .png),
  SVG 拒绝(同源脚本);内容 sha256 做 key(2026/09/{哈希前12位}{扩展名}),
  同内容重复上传自动去重复用
- GET /api/admin/files(分页 + 文件名搜索)、DELETE /{id}(先删对象再删行,
  存储端失败保留行可重试)
- 公开路由 GET /uploads/{key}(main.go 挂载):按 key 查行、存储层流式返回,
  Cache-Control immutable + ETag 304;R2 + PublicBase 时 302 直链(后端不出流量)
- URL 解析:FileURL(store, key, PublicBase)——R2 且配了公开域名走直链,
  否则 /uploads/ 流式

后台文件管理页(FilesView,「工作台 → 文件」)
- 点击 / 拖拽多选上传(uploadFiles 走 FormData 裸 fetch,401 广播与
  request() 一致);缩略图卡片网格(图片出图、其他出类型占位);
  复制链接(clipboard,非 https 回落 prompt)/ 打开 / 删除(确认提示);
  分页、loading/empty 沿用既有模式

编辑器联动(EditorView)
- 封面:URL 输入框旁「上传」按钮,选图自动填 cover_url
- wysiwyg:Crepe ImageBlock 官方 onUpload 钩子——粘贴 / 拖拽 / 插图
  全部走上传,返回 URL 后由 Crepe 插节点
- Markdown 模式:插图弹层加「上传」按钮 + 编辑器粘贴 / 拖拽图片,
  上传后在原光标处插入 ![](url)(异步上传先记光标位,逐张追踪偏移)

.env.example
- 模板入库(无敏感值):Endpoint = 公开访问域名(直链)、S3Api = 上传端点、
  Bucket / AccessKey / SecretAccessKey

验证
- 本地兜底全流程:上传 201、同内容去重复用、随机字节改名 .png 被
  内容嗅探拒绝、.svg 拒绝、公开路由 immutable 缓存头 + 内容一致、
  删除后存储与公开路由双清 404
- R2 真实链路(站主 .env):上传 store=r2、直链 200(过程中定位并修复
  S3Api 路径段折进 key 导致的直链 404,见 EndpointKeyPrefix)
- 后端 go build/test/vet 全绿;前端构建通过
2026-09-27 16:26:11 +08:00

75 lines
2.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"context"
"fmt"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// R2Store 走 Cloudflare R2 的 S3 兼容 API。endpoint 是账户级端点
// (https://<accountid>.r2.cloudflarestorage.com),R2 两种寻址都支持,
// 这里用 path-style 最稳。
//
// 端点 URL 里的路径段(如果有)不交给 SDK —— path-style 下它会被折进
// 对象 key,导致「数据库 key」和「实际对象 key」对不上(直链 404)。
// 这里只取 scheme://host 当端点;路径段由 EndpointKeyPrefix 暴露给
// 上传 handler 拼进 key,三处(存储 / 数据库 / 直链)从此一致。
type R2Store struct {
client *s3.Client
bucket string
}
func NewR2(endpoint, bucket, accessKey, secretKey string) (*R2Store, error) {
if endpoint == "" || bucket == "" {
return nil, fmt.Errorf("r2: endpoint/bucket required")
}
// R2 不认 region,SDK 又必须有——官方文档给的值就是 "auto"。
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion("auto"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
config.WithBaseEndpoint(StripEndpointPath(endpoint)),
)
if err != nil {
return nil, fmt.Errorf("r2: load aws config: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
return &R2Store{client: client, bucket: bucket}, nil
}
func (s *R2Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: r,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
})
return err
}
func (s *R2Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, 0, err
}
return out.Body, *out.ContentLength, nil
}
func (s *R2Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}