feat: download url

This commit is contained in:
zhr 2026-09-18 18:27:49 +08:00
parent 0c821ffa16
commit bebf76e582
15 changed files with 294 additions and 37 deletions

View File

@ -202,8 +202,8 @@ Java / Swift 示例:`docs/clients/`,OpenAPI 里也可点开。
| purpose | object_key | 生命周期 |
| --- | --- | --- |
| `avatar` | `perm/avatar/{yyyyMMdd}/{name}.{ext}` | 永久 |
| `group_avatar` | `perm/group/{群号}/{name}.{ext}` | 永久 |
| `avatar` | `perm/avatar/{yyyyMMdd}/{name}.{ext}` | 永久;匿名 GET |
| `group_avatar` | `perm/group/{群号}/{name}.{ext}` | 永久;匿名 GET |
| `chat_image` | `chat/image/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
| `chat_voice` | `chat/voice/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
| `chat_video` | `chat/video/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
@ -217,6 +217,8 @@ Java / Swift 示例:`docs/clients/`,OpenAPI 里也可点开。
合法 key:以 `perm/` 或 `chat/` 开头;禁止 `..`、空白、`\`、前导 `/`;最长 512。
头像匿名读:`perm/avatar/`、`perm/group/` 可直接 GET `{public_endpoint}/spark/{key}`(Nginx 也可省略 `/spark`)。聊天、收藏、朋友圈仍须 `POST /v1/files/downloads`。
发图片:客户端只 PUT 原图,再 `POST /v1/files/variants` 让服务端出缩略图和预览。消息里只存 key。
进程访问 RustFS 用内网 `object_storage.endpoint`(Docker 里是 `rustfs:9000`),签给客户端的 URL 用 `public_endpoint`(`https://sparkr.wyrs365.com`)。

View File

@ -27,6 +27,7 @@ spark-server (Docker):
- Image / config / logs: `/data/spark-server/{images,configs,logs}`
- Public security group: 443 only
- Clients upload/download at `https://sparkr.wyrs365.com` (path-style). spark-server talks to `rustfs:9000` on the Docker network and signs URLs with `object_storage.public_endpoint`.
- Unsigned GET is allowed only for `/perm/avatar/` and `/perm/group/` (rewritten to `/spark/...`). Chat and other prefixes stay SigV4.
```bash
bash deploy/nginx/install-host.sh

View File

@ -1,7 +1,8 @@
# Spark RustFS S3 — sparkr.wyrs365.com
# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf).
# TLS terminates on loopback :8443; proxy HTTP to Docker rustfs on :9000.
# Do not rewrite the URI: SigV4 signs host + path.
# Signed PUT/GET keep the path as-is (/spark/...). Unsigned avatar/group
# URLs omit the bucket; only those prefixes are rewritten and publicly readable.
upstream spark_rustfs {
server 127.0.0.1:9000;
@ -46,6 +47,30 @@ server {
proxy_http_version 1.1;
chunked_transfer_encoding off;
# Path-style S3 is /{bucket}/{key}. Clients often GET {public}/{object_key}.
# Rewrite only public prefixes; chat/ and perm/fav/ stay 403 without SigV4.
location ^~ /perm/avatar/ {
rewrite ^ /spark$request_uri break;
proxy_pass http://spark_rustfs;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
proxy_cache_convert_head off;
}
location ^~ /perm/group/ {
rewrite ^ /spark$request_uri break;
proxy_pass http://spark_rustfs;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
proxy_cache_convert_head off;
}
location / {
proxy_pass http://spark_rustfs;
proxy_set_header Host $http_host;

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
@ -23,6 +24,7 @@ func (s *Store) ensureBucket(ctx context.Context, region string, chatExpireDays
}
s.applyLifecycle(ctx, chatExpireDays)
s.applyCORS(ctx)
s.applyPublicRead(ctx)
return nil
}
@ -83,3 +85,46 @@ func (s *Store) applyCORS(ctx context.Context) {
s.log.Warn("objectstore cors", zap.Error(err))
}
}
// applyPublicRead lets browsers load avatars without a presigned GET.
// chat/ and perm/fav/ stay private.
func (s *Store) applyPublicRead(ctx context.Context) {
pabCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
_, err := s.client.PutPublicAccessBlock(pabCtx, &s3.PutPublicAccessBlockInput{
Bucket: aws.String(s.bucket),
PublicAccessBlockConfiguration: &types.PublicAccessBlockConfiguration{
BlockPublicAcls: aws.Bool(false),
IgnorePublicAcls: aws.Bool(false),
BlockPublicPolicy: aws.Bool(false),
RestrictPublicBuckets: aws.Bool(false),
},
})
if err != nil {
s.log.Warn("objectstore public access block", zap.Error(err))
}
policy := publicReadPolicy(s.bucket)
_, err = s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
Bucket: aws.String(s.bucket),
Policy: aws.String(policy),
})
if err != nil {
s.log.Warn("objectstore public policy", zap.Error(err))
}
}
func publicReadPolicy(bucket string) string {
return fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadAvatars",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
"arn:aws:s3:::%[1]s/perm/avatar/*",
"arn:aws:s3:::%[1]s/perm/group/*"
]
}]
}`, bucket)
}

View File

@ -104,6 +104,16 @@ func TestGetPutNotReady(t *testing.T) {
}
}
func TestPublicReadPolicyCoversAvatarsOnly(t *testing.T) {
p := publicReadPolicy("spark")
if !strings.Contains(p, "spark/perm/avatar/*") || !strings.Contains(p, "spark/perm/group/*") {
t.Fatalf("%s", p)
}
if strings.Contains(p, "chat/") || strings.Contains(p, "perm/fav") || strings.Contains(p, "perm/moment") {
t.Fatalf("chat/fav/moment must stay private: %s", p)
}
}
func TestMapObjectErr(t *testing.T) {
err := mapObjectErr(&types.NoSuchKey{Message: aws.String("gone")})
if !errors.Is(err, errs.ErrMediaExpired) {

View File

@ -20,3 +20,8 @@ type Download struct {
Method string `json:"method"`
ExpiresIn int64 `json:"expires_in"`
}
// DownloadSet is one HTTP round-trip for up to nine Presigned GETs.
type DownloadSet struct {
Items []Download `json:"items"`
}

View File

@ -101,6 +101,36 @@ func TestCreateDownloadValidatesKey(t *testing.T) {
}
}
func TestCreateDownloadsBatch(t *testing.T) {
svc := testUpload(t)
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"})
keys := []string{
"perm/moment/image/20260918/a.jpg",
"perm/moment/image/20260918/b.jpg",
"chat/image/20260918/c.jpg",
}
got, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{ObjectKeys: keys})
if err != nil || got == nil || len(got.Items) != 3 {
t.Fatalf("%+v %v", got, err)
}
if got.Items[0].ObjectKey != keys[0] || got.Items[1].Method != "GET" || !strings.Contains(got.Items[2].DownloadURL, keys[2]) {
t.Fatalf("%+v", got.Items)
}
if _, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{}); err == nil {
t.Fatal("empty")
}
tooMany := make([]string, 10)
for i := range tooMany {
tooMany[i] = "chat/image/20260918/x.jpg"
}
if _, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{ObjectKeys: tooMany}); err == nil {
t.Fatal("too many")
}
if _, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{ObjectKeys: []string{"../secret"}}); err == nil {
t.Fatal("path escape")
}
}
func TestCreateCredentialsBatch(t *testing.T) {
svc := testUpload(t)
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 42, Language: "zh-CN"})

View File

@ -5,7 +5,6 @@ import (
"spark-server/internal/module/upload/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
@ -31,18 +30,5 @@ func (s *Service) CreateDownload(ctx context.Context, userID int64, in *Download
if !s.allow(userID) {
return nil, errs.ErrRateLimited
}
key := in.ObjectKey
if !entity.ValidObjectKey(key) {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadKeyInvalid, nil))
}
url, err := s.store.PresignGet(ctx, key, s.ttl)
if err != nil {
return nil, err
}
return &entity.Download{
ObjectKey: key,
DownloadURL: url,
Method: entity.MethodGET,
ExpiresIn: s.expiresIn(),
}, nil
return s.issueGet(ctx, in.ObjectKey)
}

View File

@ -0,0 +1,64 @@
package logic
import (
"context"
"spark-server/internal/module/upload/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
const maxDownloadBatch = 9
// BatchDownloadInput issues up to nine Presigned GETs (moment 九宫格) in one call.
type BatchDownloadInput struct {
ObjectKeys []string `json:"object_keys" validate:"required,min=1,max=9,dive,required,max=512" label:"field.object_key" errmsg:"required:validate.upload.key_required;max:validate.upload.download_items_invalid"`
}
func (s *Service) CreateDownloads(ctx context.Context, userID int64, in *BatchDownloadInput) (*entity.DownloadSet, error) {
if !s.ready() {
return nil, errs.ErrAvailable
}
if userID <= 0 {
return nil, errs.ErrUnauthorized
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if n := len(in.ObjectKeys); n < 1 || n > maxDownloadBatch {
return nil, errs.ErrInvalidArgument
}
if !s.allow(userID) {
return nil, errs.ErrRateLimited
}
items := make([]entity.Download, 0, len(in.ObjectKeys))
for _, key := range in.ObjectKeys {
got, err := s.issueGet(ctx, key)
if err != nil {
return nil, err
}
items = append(items, *got)
}
return &entity.DownloadSet{Items: items}, nil
}
func (s *Service) issueGet(ctx context.Context, key string) (*entity.Download, error) {
if !entity.ValidObjectKey(key) {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadKeyInvalid, nil))
}
url, err := s.store.PresignGet(ctx, key, s.ttl)
if err != nil {
return nil, err
}
return &entity.Download{
ObjectKey: key,
DownloadURL: url,
Method: entity.MethodGET,
ExpiresIn: s.expiresIn(),
}, nil
}

View File

@ -59,6 +59,9 @@ func init() {
KeyUploadItemsInvalid: {
ZhCN: "一次最多签发 3 个上传凭证", En: "At most 3 upload credentials per request", ZhTW: "一次最多簽發 3 個上傳憑證",
},
KeyUploadDownloadItemsInvalid: {
ZhCN: "一次最多签发 9 个下载凭证", En: "At most 9 download credentials per request", ZhTW: "一次最多簽發 9 個下載憑證",
},
KeyFavoriteMsgRequired: {
ZhCN: "请选择消息", En: "Please choose a message", ZhTW: "請選擇訊息",
},

View File

@ -81,22 +81,23 @@ const (
KeyFriendNotFriend = "friend.not_friend"
KeyFriendRequestInvalid = "friend.request_invalid"
KeyUploadPurposeRequired = "validate.upload.purpose_required"
KeyUploadPurposeInvalid = "validate.upload.purpose_invalid"
KeyUploadMIMERequired = "validate.upload.mime_required"
KeyUploadMIMEInvalid = "validate.upload.mime_invalid"
KeyUploadSizeRequired = "validate.upload.size_required"
KeyUploadSizeInvalid = "validate.upload.size_invalid"
KeyUploadSizeExceeded = "validate.upload.size_exceeded"
KeyUploadKeyRequired = "validate.upload.key_required"
KeyUploadKeyInvalid = "validate.upload.key_invalid"
KeyUploadGroupRequired = "validate.upload.group_required"
KeyUploadItemsRequired = "validate.upload.items_required"
KeyUploadItemsInvalid = "validate.upload.items_invalid"
KeyFavoriteMsgRequired = "validate.favorite.msg_required"
KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid"
KeyMessageClientMsgRequired = "validate.message.client_msg_required"
KeyMessageContentRequired = "validate.message.content_required"
KeyUploadPurposeRequired = "validate.upload.purpose_required"
KeyUploadPurposeInvalid = "validate.upload.purpose_invalid"
KeyUploadMIMERequired = "validate.upload.mime_required"
KeyUploadMIMEInvalid = "validate.upload.mime_invalid"
KeyUploadSizeRequired = "validate.upload.size_required"
KeyUploadSizeInvalid = "validate.upload.size_invalid"
KeyUploadSizeExceeded = "validate.upload.size_exceeded"
KeyUploadKeyRequired = "validate.upload.key_required"
KeyUploadKeyInvalid = "validate.upload.key_invalid"
KeyUploadGroupRequired = "validate.upload.group_required"
KeyUploadItemsRequired = "validate.upload.items_required"
KeyUploadItemsInvalid = "validate.upload.items_invalid"
KeyUploadDownloadItemsInvalid = "validate.upload.download_items_invalid"
KeyFavoriteMsgRequired = "validate.favorite.msg_required"
KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid"
KeyMessageClientMsgRequired = "validate.message.client_msg_required"
KeyMessageContentRequired = "validate.message.content_required"
KeyFieldGroupName = "field.group_name"
KeyFieldMemberUIDs = "field.member_uids"

View File

@ -638,6 +638,7 @@ paths:
summary: 当前用户资料
description: |
Header:`Authorization: Bearer <token>`(必填),可选 `X-Device-ID`、`X-Language`。无 Body。
`avatar` 是 object_key,可匿名 GET `https://sparkr.wyrs365.com/spark/{avatar}`。
operationId: getMe
parameters:
- $ref: "#/components/parameters/X-Device-ID"
@ -1107,8 +1108,8 @@ paths:
description: |
客户端只传 `purpose` / `mime` / `size`,服务端生成 object_key 并签发短时 Presigned PUT。
文件直传 RustFS,不经过 Gin。
`avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid);
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员);
`avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid,匿名 GET);
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员,匿名 GET);
聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。
朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖/换封面时删对象)。
`{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。
@ -1196,7 +1197,7 @@ paths:
post:
tags: [files]
summary: 签发对象存储下载凭证
description: 已登录用户用 object_key 换取短时 Presigned GET。key 必须是 `perm/` 或 `chat/` 前缀。
description: 已登录用户用 object_key 换取短时 Presigned GET。key 必须是 `perm/` 或 `chat/` 前缀。九宫格请用 `POST /v1/files/downloads/batch`。
operationId: createDownloadCredential
requestBody:
required: true
@ -1213,6 +1214,42 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/files/downloads/batch:
post:
tags: [files]
summary: 批量签发下载凭证
description: |
一次返回 1~9 个 Presigned GET,顺序与 `object_keys` 相同。朋友圈九宫格一次拿齐。
限流按请求计入,不按条数。文件仍直传 RustFS GET。
operationId: createDownloadCredentials
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateDownloadBatchRequest"
example:
object_keys:
- perm/moment/image/20260918/a.jpg
- perm/moment/image/20260918/b.jpg
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
example:
code: "0"
msg: ok
data:
items:
- object_key: perm/moment/image/20260918/a.jpg
download_url: https://sparkr.wyrs365.com/spark/perm/moment/image/20260918/a.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256
method: GET
expires_in: 900
- object_key: perm/moment/image/20260918/b.jpg
method: GET
/v1/favorites:
get:
tags: [favorites]
@ -1296,6 +1333,7 @@ paths:
summary: 发朋友圈
description: |
纯文,或最多 9 张图,或 1 条视频(图/视频互斥)。媒体先 `POST /v1/files`(`moment_image` / `moment_video`)再填 key。
浏览九宫格:`POST /v1/files/downloads/batch` 一次换 9 张 Presigned GET。
`visibility`:0 好友公开 1 私密 2 白名单 3 黑名单。2/3 需 `audience_uids`(好友,最多 200)。
operationId: publishMoment
requestBody:
@ -2108,6 +2146,10 @@ components:
avatar:
type: string
maxLength: 512
description: |
对象存储 key。仅 `perm/avatar/`、`perm/group/` 可匿名 GET(桶本身不公开)。
URL:`{public_endpoint}/spark/{avatar}`,Nginx 也可省略桶名 `{public_endpoint}/{avatar}`。
`chat/`、`perm/fav/`、`perm/moment/` 必须 Presigned GET。
gender:
$ref: "#/components/schemas/Gender"
birthday:
@ -2211,6 +2253,17 @@ components:
object_key:
type: string
maxLength: 512
CreateDownloadBatchRequest:
type: object
required: [object_keys]
properties:
object_keys:
type: array
minItems: 1
maxItems: 9
items:
type: string
maxLength: 512
SendMessageRequest:
type: object
required: [client_msg_id, content]

View File

@ -90,6 +90,19 @@ func TestUploadCredentialHTTP(t *testing.T) {
if err := json.Unmarshal(batch.Data, &set); err != nil || len(set.Items) != 2 || set.Items[0].ObjectKey == set.Items[1].ObjectKey {
t.Fatalf("%s %v", batch.Data, err)
}
dl := authJSON(t, h, &tok, http.MethodPost, "/v1/files/downloads/batch", `{"object_keys":["chat/image/20260917/a.jpg","perm/moment/image/20260917/b.jpg"]}`)
if dl.Code != "0" {
t.Fatalf("downloads %+v", dl)
}
var dls struct {
Items []struct {
ObjectKey string `json:"object_key"`
DownloadURL string `json:"download_url"`
} `json:"items"`
}
if err := json.Unmarshal(dl.Data, &dls); err != nil || len(dls.Items) != 2 || dls.Items[0].DownloadURL == "" {
t.Fatalf("%s %v", dl.Data, err)
}
}
func TestFavoriteTextHTTP(t *testing.T) {

View File

@ -72,6 +72,24 @@ func (h *Handler) Download(c *gin.Context) {
respond.JSON(c, out)
}
func (h *Handler) DownloadBatch(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
var in uploadlogic.BatchDownloadInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
out, err := h.files.CreateDownloads(c.Request.Context(), uid, &in)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func currentUID(c *gin.Context) (int64, bool) {
rc := reqctx.From(c.Request.Context())
if rc == nil || rc.UserID <= 0 {

View File

@ -136,6 +136,7 @@ func registerFileRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
g.POST("", h.Create)
g.POST("/batch", h.CreateBatch)
g.POST("/downloads", h.Download)
g.POST("/downloads/batch", h.DownloadBatch)
}
func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {