Skip to content

Commit 57f7ccb

Browse files
committed
feat(api): audit apis
1 parent 49a95ef commit 57f7ccb

38 files changed

Lines changed: 1402 additions & 244 deletions

File tree

apps/api/internal/about/service/service.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,12 @@ func (s *AboutService) GetPost(slug string) (*model.PostDetail, error) {
207207
if slug == "" {
208208
return nil, fmt.Errorf("slug is required")
209209
}
210-
// Forbid path traversal — slugs are POSIX paths under postsDir.
210+
// Forbid path traversal — slugs are POSIX paths under postsDir. Return
211+
// os.ErrNotExist (not a generic error) so the handler maps it to 404:
212+
// the check fires before any file read, so a traversal probe should look
213+
// like an ordinary missing article rather than a 500.
211214
if strings.Contains(slug, "..") {
212-
return nil, fmt.Errorf("invalid slug")
215+
return nil, os.ErrNotExist
213216
}
214217

215218
posts, err := s.readAll()

apps/api/internal/admin/dto/dto.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,27 @@ type AdminStatsRequest struct {
3232
Days int `query:"days" validate:"required,min=1"`
3333
}
3434

35-
// AdminStatsResponse is the response for overview stats
35+
// AdminStatsResponse is the response for overview stats.
36+
// NOTE: json tags must match the FE keys (app/shared/types/admin.d.ts +
37+
// app/constants/admin.ts ADMIN_STATS_MAP) — `new_resource`, NOT
38+
// `new_patch_resource`, or the "新发布补丁" dashboard card silently renders 0.
3639
type AdminStatsResponse struct {
3740
NewUser int64 `json:"new_user"`
3841
NewActiveUser int64 `json:"new_active_user"`
3942
NewGalgame int64 `json:"new_galgame"`
40-
NewPatchResource int64 `json:"new_patch_resource"`
43+
NewPatchResource int64 `json:"new_resource"`
4144
NewComment int64 `json:"new_comment"`
4245
}
4346

44-
// AdminStatsSumResponse is the response for total stats
47+
// AdminStatsSumResponse is the response for total stats.
48+
// json tags must match the FE keys (ADMIN_STATS_SUM_MAP + SumData):
49+
// `resource_count` / `comment_count`, NOT `patch_*_count`, or those two
50+
// dashboard cards silently render 0.
4551
type AdminStatsSumResponse struct {
4652
UserCount int64 `json:"user_count"`
4753
GalgameCount int64 `json:"galgame_count"`
48-
PatchResourceCount int64 `json:"patch_resource_count"`
49-
PatchCommentCount int64 `json:"patch_comment_count"`
54+
PatchResourceCount int64 `json:"resource_count"`
55+
PatchCommentCount int64 `json:"comment_count"`
5056
}
5157

5258
// PurgeUserRequest is the body for POST /admin/user/:id/purge. When

apps/api/internal/admin/repository/repository.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,30 @@ func (r *AdminRepository) UpdateComment(commentID int, content string) error {
6161
}
6262

6363
func (r *AdminRepository) DeleteComment(commentID int) error {
64-
return r.db.Delete(&patchModel.PatchComment{}, commentID).Error
64+
// Mirror PatchService.DeleteComment so the denormalized patch.comment_count
65+
// stays consistent after admin moderation (the plain Delete left it drifting
66+
// upward). Only approved (status=0) rows were ever added to the count, so
67+
// subtract only the approved comment + its direct approved replies.
68+
return r.db.Transaction(func(tx *gorm.DB) error {
69+
var comment patchModel.PatchComment
70+
if err := tx.First(&comment, commentID).Error; err != nil {
71+
return err
72+
}
73+
var count int64
74+
tx.Model(&patchModel.PatchComment{}).
75+
Where("(id = ? OR parent_id = ?) AND status = 0", commentID, commentID).
76+
Count(&count)
77+
if err := tx.Delete(&patchModel.PatchComment{}, commentID).Error; err != nil {
78+
return err
79+
}
80+
if count > 0 {
81+
if err := tx.Model(&patchModel.Patch{}).Where("id = ?", comment.GalgameID).
82+
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", count)).Error; err != nil {
83+
return err
84+
}
85+
}
86+
return nil
87+
})
6588
}
6689

6790
// ===== Resources =====

apps/api/internal/app/router.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,14 @@ func (a *App) RegisterRoutes() {
7979
// 30/min per userID (or per IP when anonymous) keeps legitimate browsing
8080
// (one user opens 10 patch resource pages = ~10-30 calls) untouched
8181
// while breaking automated scraping. Returns 429 on overflow.
82+
// optionalAuth runs BEFORE the limiter so it can key by userID for
83+
// logged-in callers (the documented "30/min per userID, per IP when
84+
// anonymous"). Without it the user context is never populated and the
85+
// limiter always falls back to IP — collectively throttling logged-in
86+
// users behind a shared NAT/proxy.
8287
patchRoutes.Get(
8388
"/resource/:resourceId/link",
89+
optionalAuth,
8490
middleware.RateLimit(a.RDB, "resource-link", 30, time.Minute),
8591
a.PatchHandler.GetResourceDownloadInfo,
8692
)
@@ -182,7 +188,13 @@ func (a *App) RegisterRoutes() {
182188
msgRoutes.Get("/", a.MessageHandler.GetMessages)
183189
msgRoutes.Get("/all", a.MessageHandler.GetAllMessages)
184190
msgRoutes.Get("/unread", a.MessageHandler.GetUnreadTypes)
185-
msgRoutes.Post("/", a.MessageHandler.CreateMessage)
191+
// NOTE: POST /message was removed (API audit 2026-05-29). It let ANY
192+
// authenticated user write an arbitrary notification (client-controlled
193+
// recipient_id / type / content / link, no rate limit) into ANY other
194+
// user's inbox — a spam/phishing primitive. It had no frontend caller;
195+
// all legitimate notifications are created server-side via the patch
196+
// service's createDedupMessage. Re-add only with recipient restricted to
197+
// an existing relationship + enum-validated type + rate limiting.
186198
msgRoutes.Put("/read", a.MessageHandler.MarkAsRead)
187199

188200
// ===== Admin Routes =====

apps/api/internal/chat/dto/dto.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ type ListMessagesQuery struct {
3838
IDs string `query:"ids" validate:"omitempty,max=2000"`
3939
After int `query:"after" validate:"min=0"`
4040
Before int `query:"before" validate:"min=0"`
41-
Limit int `query:"limit" validate:"min=1,max=100"`
41+
// omitempty so an omitted limit (0) falls through to the handler's default
42+
// of 30 instead of 422-ing. Previously `min=1` rejected limit-less calls
43+
// (notably the `ids` exact-set refresh mode) before the default ran.
44+
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
4245
}
4346

4447
// CreateMessageRequest sends a message. FileURL is optional (attachment).

apps/api/internal/chat/service/service.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,21 @@ func (s *ChatService) DeleteMessage(userID int, isPrivileged bool, messageID int
159159

160160
// ToggleReaction toggles an emoji reaction.
161161
func (s *ChatService) ToggleReaction(userID, messageID int, emoji string) (bool, error) {
162-
if _, err := s.repo.GetMessage(messageID); err != nil {
162+
m, err := s.repo.GetMessage(messageID)
163+
if err != nil {
163164
return false, fmt.Errorf("消息不存在")
164165
}
166+
// Membership check: every other message-scoped op resolves the room and
167+
// verifies membership; without it here, any authenticated user could
168+
// add/remove reactions on messages inside PRIVATE rooms they don't belong
169+
// to (IDOR). Reactions need no moderator bypass.
170+
ok, err := s.repo.IsMember(userID, m.ChatRoomID)
171+
if err != nil {
172+
return false, err
173+
}
174+
if !ok {
175+
return false, fmt.Errorf("您不是该房间的成员")
176+
}
165177
return s.repo.ToggleReaction(messageID, userID, emoji)
166178
}
167179

apps/api/internal/common/handler.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ func (h *CommonHandler) GetHome(c *fiber.Ctx) error {
123123
h.attachResourceUsers(c.Context(), resources)
124124
h.attachCommentUsers(c.Context(), comments)
125125
h.attachPatchSummaries(c, comments, resources)
126+
// Home cards never render download links/secrets — strip them so this
127+
// public feed can't be scraped for download URLs / codes / passwords
128+
// (the rate-limited /patch/resource/:id/link is the only reveal surface).
129+
patchModel.StripResourceSecrets(resources)
126130

127131
return response.OK(c, homeResponse{
128132
Galgames: enricher.EnrichPatches(c.Context(), h.wiki, h.users, patches, cl),
@@ -349,6 +353,11 @@ func (h *CommonHandler) GetGlobalResources(c *fiber.Ctx) error {
349353
patchModel.RenderResourceNotes(resources)
350354
h.attachResourceUsers(c.Context(), resources)
351355
h.attachPatchSummaries(c, nil, resources)
356+
// Global resource feed cards never render the download payload — strip it
357+
// so this public, paginated (whole table) feed can't be bulk-scraped for
358+
// download URLs / codes / passwords, which would defeat the rate-limited
359+
// /patch/resource/:id/link reveal endpoint.
360+
patchModel.StripResourceSecrets(resources)
352361
return response.Paginated(c, resources, total)
353362
}
354363

@@ -445,6 +454,11 @@ func (h *CommonHandler) GetResourceDetail(c *fiber.Ctx) error {
445454
h.attachResourceUsers(c.Context(), one)
446455
resource = one[0]
447456
h.attachResourceUsers(c.Context(), recs)
457+
// Recommendation cards only show name/note/stats — strip their download
458+
// payload so the recs sidebar can't be walked to harvest links/secrets.
459+
// The main `resource` keeps them: it is the intended single-reveal surface
460+
// (the detail page renders its download links directly).
461+
patchModel.StripResourceSecrets(recs)
448462

449463
// Viewer-specific state (if logged in): is_liked on the main resource +
450464
// recommendations, and is_favorite on the owning patch — so the

apps/api/internal/common/upload/service.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ func (s *Service) markCompleteOnce(ctx context.Context, s3Key string) (bool, err
7878
return res == "OK", nil
7979
}
8080

81+
// unmarkComplete releases the idempotency marker for s3Key, used when a
82+
// complete attempt set the marker but then failed before the quota deduction
83+
// committed (so a retry can re-run the deduction). Best-effort, and uses a
84+
// fresh context because the request context may already be done by the time
85+
// this runs in a deferred cleanup.
86+
func (s *Service) unmarkComplete(s3Key string) {
87+
if s.rdb == nil {
88+
return
89+
}
90+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
91+
defer cancel()
92+
s.rdb.Del(ctx, "upload:complete:"+s3Key)
93+
}
94+
8195
// ─── s3_key generation ───────────────────────────────
8296

8397
var (
@@ -190,6 +204,17 @@ func (s *Service) verifyAndFinalize(ctx context.Context, userID int, s3Key strin
190204
if !first {
191205
return actual, nil
192206
}
207+
// The idempotency marker is now held. It must persist ONLY if the quota
208+
// deduction below actually commits — otherwise a retry hits the "!first"
209+
// fast-path above and returns success WITHOUT ever deducting, permanently
210+
// under-counting daily_upload_size. Release the marker on every failure
211+
// path so a retry can re-run the deduction (MOYU-PR7 / M5 follow-up).
212+
deducted := false
213+
defer func() {
214+
if !deducted {
215+
s.unmarkComplete(s3Key)
216+
}
217+
}()
193218

194219
var user authModel.User
195220
if err := s.db.Select("daily_upload_size").First(&user, userID).Error; err != nil {
@@ -205,6 +230,7 @@ func (s *Service) verifyAndFinalize(ctx context.Context, userID int, s3Key strin
205230
UpdateColumn("daily_upload_size", gorm.Expr("daily_upload_size + ?", actual)).Error; err != nil {
206231
return 0, fmt.Errorf("扣减限额失败: %w", err)
207232
}
233+
deducted = true
208234
return actual, nil
209235
}
210236

@@ -247,6 +273,14 @@ func (s *Service) InitMultipart(ctx context.Context, userID int, privileged bool
247273
if err := s.validatePreUpload(userID, req.FileName, req.FileSize, privileged); err != nil {
248274
return nil, err
249275
}
276+
// part_count must match the fixed 10 MiB chunking the client uses (FE
277+
// computes ceil(file_size / MULTIPART_PART_SIZE) with the same constant).
278+
// Reject mismatches so a client can't decouple part_count from the real
279+
// size and force the server to presign thousands of bogus part URLs.
280+
wantParts := int((req.FileSize + constants.MultipartPartSize - 1) / constants.MultipartPartSize)
281+
if req.PartCount != wantParts {
282+
return nil, fmt.Errorf("分片数不正确:应为 %d", wantParts)
283+
}
250284

251285
key, err := buildPatchResourceKey(req.GalgameID, req.FileName)
252286
if err != nil {

apps/api/internal/galgame/enricher/enricher.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,13 @@ func EnrichPatchDetail(ctx context.Context, wiki *galgameClient.Client, users *u
338338
base := &PatchDetailCard{}
339339
base.GalgameCard = baseCard(p)
340340
base.Updated = p.Updated
341+
// Initialize the Wiki-derived slices to non-nil so an empty set serializes
342+
// as [] (not JSON null). The FE types declare them as non-optional arrays
343+
// (tags/officials/wiki_engine_ids); a null would break any .map/.length the
344+
// detail page does without a guard. Applies to every return path below.
345+
base.Tags = []PatchDetailTag{}
346+
base.Officials = []PatchDetailOfficial{}
347+
base.WikiEngineIDs = []int{}
341348

342349
if users != nil && p.UserID > 0 {
343350
if b, _ := users.User(ctx, uint(p.UserID)); b != nil {
@@ -506,7 +513,16 @@ func CardFromBrief(g *galgameClient.GalgameBrief) GalgameCard {
506513
if g == nil {
507514
return GalgameCard{}
508515
}
509-
card := GalgameCard{ID: g.ID, VndbID: g.VndbID}
516+
// Init the JSONArray fields to non-nil so they serialize as [] not null —
517+
// this degraded card (a Wiki galgame with no local patch row) has no local
518+
// type/language/platform, and the FE type declares them as string[].
519+
card := GalgameCard{
520+
ID: g.ID,
521+
VndbID: g.VndbID,
522+
Type: patchModel.JSONArray{},
523+
Language: patchModel.JSONArray{},
524+
Platform: patchModel.JSONArray{},
525+
}
510526
applyGalgame(&card, g)
511527
return card
512528
}

apps/api/internal/message/dto/dto.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,6 @@ type GetMessageRequest struct {
1111
Limit int `query:"limit" validate:"required,min=1,max=50"`
1212
}
1313

14-
// CreateMessageRequest is the request for creating a message
15-
type CreateMessageRequest struct {
16-
Type string `json:"type" validate:"required"`
17-
Content string `json:"content" validate:"required,max=1007"`
18-
RecipientID int `json:"recipient_id" validate:"required,min=1"`
19-
Link string `json:"link" validate:"max=1007"`
20-
}
21-
2214
// ReadMessageRequest is the request for marking messages as read
2315
type ReadMessageRequest struct {
2416
Type string `json:"type" validate:"required,max=20"`

0 commit comments

Comments
 (0)