Skip to content

Commit 232ffaa

Browse files
authored
feat(sticker): return one client-renderable path for all stickers (#535)
GET /v1/sticker/user mixed two path shapes in the same `path` field: self-uploaded stickers stored an absolute anonymous-GET download URL, while collected stickers stored the auth-gated `file/preview/...` key, which is not renderable in an <img> (AuthMiddleware only reads the token from a header). Clients had to branch on the path shape. Normalize server-side so `path` is always directly renderable: - Add a 1-method `stickerURLResolver` (satisfied by file.IService) and resolve every stored path through a single `toResp` mapper used by list/add/collect/update. Absolute URLs pass through; object keys (and any leading `file/preview/`) resolve via DownloadURL to the permanent public/CDN URL. Resolve failure falls back to the stored value (warned). - collect stores the bare object key instead of the preview path, keeping storage backend-agnostic. Idempotency stays keyed on SourcePathHash. - No new response field, no client change, no migration: legacy rows normalize on read. Security: reject `.`/`..` path segments at collect ingress and again at the renderablePath URL-generation boundary. Without this, a stored `sticker/../a.png` would resolve through url.JoinPath (which cleans `..`) to an object outside the sticker keyspace. Verified the escape only occurs for a literal `..` middle segment. Tests: renderablePath unit tests (passthrough, key resolution, prefix strip, traversal fallback, error/empty/nil fallback); add/collect integration assertions updated to the normalized contract plus traversal-rejection cases.
1 parent b413ca6 commit 232ffaa

4 files changed

Lines changed: 244 additions & 13 deletions

File tree

modules/sticker/api.go

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@
2525
package sticker
2626

2727
import (
28+
"strings"
29+
2830
"github.com/Mininglamp-OSS/octo-lib/config"
2931
"github.com/Mininglamp-OSS/octo-lib/pkg/log"
3032
"github.com/Mininglamp-OSS/octo-lib/pkg/util"
3133
"github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp"
3234
commonmod "github.com/Mininglamp-OSS/octo-server/modules/common"
35+
filemod "github.com/Mininglamp-OSS/octo-server/modules/file"
3336
"github.com/Mininglamp-OSS/octo-server/pkg/errcode"
3437
"github.com/Mininglamp-OSS/octo-server/pkg/httperr"
3538
"github.com/Mininglamp-OSS/octo-server/pkg/metrics"
@@ -50,12 +53,25 @@ const (
5053
defaultStickerPlaceholder = "[表情]"
5154
)
5255

56+
// stickerURLResolver resolves a stored sticker object key into a directly
57+
// renderable, anonymous-GET download URL. It is the single capability the
58+
// sticker module needs from modules/file; kept as a 1-method interface so tests
59+
// inject a deterministic fake instead of a real storage backend. Satisfied by
60+
// file.IService (modules/file).
61+
type stickerURLResolver interface {
62+
DownloadURL(path string, filename string) (string, error)
63+
}
64+
5365
// Sticker 用户自定义贴纸 API。
5466
type Sticker struct {
5567
ctx *config.Context
5668
log.Log
5769
db *stickerDB
5870
settings *commonmod.SystemSettings
71+
// fileURL resolves a stored path into a client-renderable URL. See
72+
// renderablePath — this is what lets GET /v1/sticker/user return one uniform
73+
// renderable `path` for both self-uploaded and collected stickers.
74+
fileURL stickerURLResolver
5975
}
6076

6177
// New 创建 Sticker 实例。settings 走进程内共享单例,配额变更(管理端写
@@ -66,6 +82,7 @@ func New(ctx *config.Context) *Sticker {
6682
Log: log.NewTLog("Sticker"),
6783
db: newStickerDB(ctx),
6884
settings: commonmod.EnsureSystemSettings(ctx),
85+
fileURL: filemod.NewService(ctx),
6986
}
7087
// 运营可见性:签发/校验 handle 的「能力」由 OCTO_MASTER_KEY 决定(stickersig.Enabled,
7188
// 部署级 env),「是否强制客户端必须带 handle」的「策略」由 system_setting
@@ -105,6 +122,62 @@ func (s *Sticker) Route(r *wkhttp.WKHttp) {
105122
}
106123
}
107124

125+
// renderablePath normalizes a stored sticker path into a value the client can
126+
// drop straight into an <img src> — no per-client "absolute vs relative" branch.
127+
//
128+
// Two shapes reach the DB:
129+
// - self-uploaded stickers store the /v1/file/upload response `path`, which is
130+
// already an absolute anonymous-GET download/CDN URL → passed through as-is.
131+
// - collected stickers (and any relative object key) store the object key. The
132+
// authenticated /v1/file/preview/<key> endpoint cannot be used directly in an
133+
// <img> because AuthMiddleware only reads the token from a header, so we
134+
// resolve the key through the file service's DownloadURL — the same permanent
135+
// public/CDN URL /v1/file/preview redirects to.
136+
//
137+
// Any leading "file/preview/" is stripped before resolving so legacy collected
138+
// rows (which stored the preview path) normalize identically to new rows (which
139+
// store the bare key). On resolve failure the stored value is returned unchanged
140+
// — no worse than the pre-normalization behavior.
141+
func (s *Sticker) renderablePath(stored string) string {
142+
if stored == "" {
143+
return stored
144+
}
145+
if strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") {
146+
return stored
147+
}
148+
if s.fileURL == nil {
149+
return stored
150+
}
151+
key := strings.TrimPrefix(stored, "file/preview/")
152+
// Defense-in-depth against keyspace escape: never hand a "."/".." segment to
153+
// DownloadURL (url.JoinPath resolves "..", escaping the sticker/ prefix). The
154+
// collect ingress already rejects such keys, but this also confines any
155+
// legacy row or the self-upload path. A traversal key falls back to the
156+
// stored value (a broken but non-escaping render), matching pre-change
157+
// behavior where /v1/file/preview rejected "..".
158+
if hasUnsafeSegment(key) {
159+
return stored
160+
}
161+
url, err := s.fileURL.DownloadURL(key, "")
162+
if err != nil || url == "" {
163+
// Fail open, but make silent degradation observable: on a misconfigured
164+
// backend every list item would quietly return its raw stored value.
165+
// Low-cardinality — the error, never the per-user path, is logged.
166+
s.Warn("解析贴纸渲染 URL 失败,回退到存储原值", zap.Error(err))
167+
return stored
168+
}
169+
return url
170+
}
171+
172+
// toResp maps a model to the wire shape with a client-renderable path. Every
173+
// endpoint that returns a sticker funnels through here so the `path` contract is
174+
// uniform across list/add/collect/update.
175+
func (s *Sticker) toResp(m *StickerModel) stickerResp {
176+
r := toStickerResp(m)
177+
r.Path = s.renderablePath(r.Path)
178+
return r
179+
}
180+
108181
// list 返回当前用户的自定义贴纸(扁平列表,最新在前)。空集合返回
109182
// {"list":[]} 而非 404 —— 正是 issue #26 要消灭的噪音。
110183
func (s *Sticker) list(ctx *wkhttp.Context) {
@@ -119,7 +192,7 @@ func (s *Sticker) list(ctx *wkhttp.Context) {
119192

120193
list := make([]stickerResp, 0, len(models))
121194
for _, m := range models {
122-
list = append(list, toStickerResp(m))
195+
list = append(list, s.toResp(m))
123196
}
124197
ctx.Response(listStickerResp{List: list})
125198
}
@@ -331,13 +404,14 @@ func (s *Sticker) add(ctx *wkhttp.Context) {
331404
zap.Bool("handle_required", stickersig.Enabled()),
332405
zap.Bool("shortcode_set", shortcode != ""),
333406
zap.Int("keyword_count", len(decodeStickerKeywords(keywordsStore))))
334-
ctx.Response(toStickerResp(m))
407+
ctx.Response(s.toResp(m))
335408
}
336409

337410
// collect adds a sticker sent by another user into the caller's personal
338411
// sticker list. Unlike add(), the source path is not required to belong to the
339412
// caller; it must still point at the reserved sticker object keyspace. The
340-
// stored path is a stable authenticated preview URL for that source object, and
413+
// stored path is the bare source object key; the response (and later list)
414+
// resolve it to a client-renderable URL via toResp → renderablePath.
341415
// SourcePathHash makes repeat taps idempotent without charging quota again.
342416
//
343417
// collect intentionally does not require the upload handle even when
@@ -437,7 +511,7 @@ func (s *Sticker) collect(ctx *wkhttp.Context) {
437511
return
438512
}
439513
observeStickerCollect("idempotent_hit")
440-
ctx.Response(toStickerResp(existing))
514+
ctx.Response(s.toResp(existing))
441515
return
442516
}
443517

@@ -467,9 +541,14 @@ func (s *Sticker) collect(ctx *wkhttp.Context) {
467541
}
468542

469543
m := &StickerModel{
470-
StickerID: util.GenerUUID(),
471-
UID: loginUID,
472-
Path: source.DisplayPath,
544+
StickerID: util.GenerUUID(),
545+
UID: loginUID,
546+
// Store the bare object key, not the auth-gated file/preview/ path. The
547+
// list/collect responses resolve it to a renderable URL via toResp →
548+
// renderablePath; storing the key keeps the DB backend-agnostic (a CDN /
549+
// download-host change re-resolves correctly instead of stranding a
550+
// baked-in preview path).
551+
Path: source.SourceKey,
473552
Placeholder: placeholder,
474553
Format: source.Format,
475554
Sort: req.Sort,
@@ -499,7 +578,7 @@ func (s *Sticker) collect(ctx *wkhttp.Context) {
499578
zap.String("format", source.Format),
500579
zap.Bool("shortcode_set", shortcode != ""),
501580
zap.Int("keyword_count", len(decodeStickerKeywords(keywordsStore))))
502-
ctx.Response(toStickerResp(m))
581+
ctx.Response(s.toResp(m))
503582
}
504583

505584
// update partially updates the current user's sticker metadata. Missing fields
@@ -596,7 +675,7 @@ func (s *Sticker) update(ctx *wkhttp.Context) {
596675
return
597676
}
598677

599-
ctx.Response(toStickerResp(m))
678+
ctx.Response(s.toResp(m))
600679
}
601680

602681
// stickerPathClass is the outcome of authorizing a client-supplied sticker

modules/sticker/api_test.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"net/http/httptest"
99
"strconv"
10+
"strings"
1011
"sync"
1112
"testing"
1213

@@ -156,7 +157,7 @@ func TestSticker_ListEmpty(t *testing.T) {
156157
}
157158

158159
func TestSticker_AddAndList(t *testing.T) {
159-
route, _, _ := setupSticker(t)
160+
route, _, f := setupSticker(t)
160161

161162
add := doRequest(t, route, "POST", "/v1/sticker/user", map[string]string{
162163
"path": validStickerPath("abc.png"),
@@ -169,14 +170,24 @@ func TestSticker_AddAndList(t *testing.T) {
169170
assert.NotEmpty(t, ab["sticker_id"])
170171
assert.Equal(t, "user", ab["category"])
171172
assert.Equal(t, "png", ab["format"])
173+
// The immediate add response is normalized too (all four endpoints funnel
174+
// through toResp), not just the later list read.
175+
wantPath := f.renderablePath(validStickerPath("abc.png"))
176+
assert.Equal(t, wantPath, ab["path"])
177+
assert.False(t, strings.HasPrefix(ab["path"].(string), "file/preview/"),
178+
"add response path must be a renderable URL, not the auth-gated preview key")
172179

173180
w := doRequest(t, route, "GET", "/v1/sticker/user", nil)
174181
body := parseJSON(t, w)
175182
list, ok := body["list"].([]interface{})
176183
require.True(t, ok)
177184
require.Equal(t, 1, len(list))
178185
item := list[0].(map[string]interface{})
179-
assert.Equal(t, validStickerPath("abc.png"), item["path"])
186+
// path is normalized to a client-renderable URL (same transform the handler
187+
// applies), never the raw auth-gated file/preview/ key.
188+
assert.Equal(t, wantPath, item["path"])
189+
assert.False(t, strings.HasPrefix(item["path"].(string), "file/preview/"),
190+
"list path must be a renderable URL, not the auth-gated preview key")
180191
assert.Equal(t, "user", item["category"])
181192
assert.Equal(t, "[笑]", item["placeholder"])
182193
}
@@ -196,7 +207,12 @@ func TestSticker_CollectForeignPathIdempotent(t *testing.T) {
196207
require.Equal(t, http.StatusOK, first.Code, "body: %s", first.Body.String())
197208
firstBody := parseJSON(t, first)
198209
firstID := firstBody["sticker_id"].(string)
199-
assert.Equal(t, sourcePath, firstBody["path"])
210+
// Collect stores the bare object key; the response path is the normalized
211+
// renderable URL (never the auth-gated file/preview/ key the client sent).
212+
sourceKey := "sticker/source-uid/foreign.png"
213+
assert.Equal(t, f.renderablePath(sourceKey), firstBody["path"])
214+
assert.False(t, strings.HasPrefix(firstBody["path"].(string), "file/preview/"),
215+
"collect path must be a renderable URL, not the auth-gated preview key")
200216
assert.Equal(t, "png", firstBody["format"])
201217
assert.Equal(t, "[收藏]", firstBody["placeholder"])
202218
assert.Equal(t, "fav_one", firstBody["shortcode"])
@@ -216,7 +232,8 @@ func TestSticker_CollectForeignPathIdempotent(t *testing.T) {
216232
stickers, err := f.db.listByUID(testutil.UID)
217233
require.NoError(t, err)
218234
require.Len(t, stickers, 1)
219-
assert.Equal(t, sourcePath, stickers[0].Path)
235+
assert.Equal(t, sourceKey, stickers[0].Path, "collect stores the bare object key, not the preview path")
236+
assert.Equal(t, sourceKey, stickers[0].SourcePath)
220237
assert.NotEmpty(t, stickers[0].SourcePathHash)
221238
}
222239

@@ -281,6 +298,12 @@ func TestSticker_CollectRejectsInvalidSourcePath(t *testing.T) {
281298
{"unsupported extension", "file/preview/sticker/source-uid/x.tiff"},
282299
{"missing extension", "file/preview/sticker/source-uid/x"},
283300
{"nested object", "file/preview/sticker/source-uid/nested/x.png"},
301+
// Path-traversal keys must be rejected at ingress so they never reach
302+
// storage or renderablePath → DownloadURL (which would resolve ".." and
303+
// escape the sticker/ keyspace to the bucket root).
304+
{"parent traversal segment", "sticker/../x.png"},
305+
{"parent traversal via preview prefix", "file/preview/sticker/../x.png"},
306+
{"parent traversal via absolute url", "https://cdn.example.com/bucket/sticker/../x.png"},
284307
}
285308
for _, tc := range cases {
286309
t.Run(tc.name, func(t *testing.T) {

modules/sticker/model.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,38 @@ func parseCollectStickerObjectKey(candidate string, re *regexp.Regexp) (collectS
212212
return collectStickerSource{}, false
213213
}
214214
sourceKey := "sticker/" + m[1] + "/" + m[2] + "." + m[3]
215+
// Reject path-traversal / relative segments before the key is ever stored.
216+
// The regex's `[^/]+` matches "." and "..", so `sticker/../a.png` parses to
217+
// SourceKey "sticker/../a.png". That key is later resolved by renderablePath
218+
// via DownloadURL → url.JoinPath, which RESOLVES "..", collapsing the
219+
// "sticker/" prefix and escaping the sticker keyspace to the bucket root
220+
// (e.g. "<base>/bucket/a.png"). Confining at ingress keeps traversal keys out
221+
// of the DB and out of every downstream consumer (this one and file/preview).
222+
if hasUnsafeSegment(sourceKey) {
223+
return collectStickerSource{}, false
224+
}
215225
return collectStickerSource{
216226
SourceKey: sourceKey,
217227
DisplayPath: "file/preview/" + sourceKey,
218228
Format: format,
219229
}, true
220230
}
221231

232+
// hasUnsafeSegment reports whether key contains a "." or ".." path segment. A
233+
// ".." segment lets url.JoinPath (used by every storage backend's DownloadURL)
234+
// escape the object key's intended prefix; "." is normalized away and never
235+
// legitimate in a generated sticker key. Percent-encoded forms ("%2e%2e") are
236+
// intentionally NOT decoded here: url.JoinPath leaves them literal (verified),
237+
// so they cannot traverse — only literal segments can.
238+
func hasUnsafeSegment(key string) bool {
239+
for _, seg := range strings.Split(key, "/") {
240+
if seg == "." || seg == ".." {
241+
return true
242+
}
243+
}
244+
return false
245+
}
246+
222247
func stickerSourcePathHash(sourceKey string) string {
223248
sum := sha256.Sum256([]byte(sourceKey))
224249
return hex.EncodeToString(sum[:])

0 commit comments

Comments
 (0)