2525package sticker
2626
2727import (
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。
5466type 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 要消灭的噪音。
110183func (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
0 commit comments