Skip to content

Commit bce602f

Browse files
committed
feat(comment): reader image upload, quotas, ttl cleanup, admin mgmt
- ReaderAuthGuard + CommentUploadController (POST /comments/uploads, GET /comments/uploads/config) with per-reader quota interceptor - file-type magic-byte mime detection (jpeg/png/webp/gif) - markdown parse + attach/detach/cascade in CommentService; reject 403/409/422 for cross-reader / double-bind / cap exceeded - cron pending+detached cleanup (15m); hardDeleteFile with structured stdout audit log (no DB collection) - admin endpoints GET/DELETE /comments-uploads for management UI - new CommentUploadOptionsSchema (ttl, size, mime, quotas, gates) + commentUploadPrefix on imageStorageOptions; surfaced under storage group in admin form schema - @mx-space/api-client: comment.getUploadConfig + uploadImage
1 parent 02fd3ec commit bce602f

30 files changed

Lines changed: 1430 additions & 27 deletions

apps/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"dotenv-expand": "^13.0.0",
103103
"ejs": "5.0.2",
104104
"es-toolkit": "^1.46.0",
105+
"file-type": "^22.0.1",
105106
"form-data": "4.0.5",
106107
"inquirer": "^13.4.2",
107108
"isbot": "5.1.39",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { UseGuards } from '@nestjs/common'
2+
3+
import { ReaderAuthGuard } from '../guards/reader-auth.guard'
4+
5+
export function ReaderAuth() {
6+
return UseGuards(ReaderAuthGuard)
7+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { CanActivate, ExecutionContext } from '@nestjs/common'
2+
import { Injectable } from '@nestjs/common'
3+
4+
import { ErrorCodeEnum } from '~/constants/error-code.constant'
5+
import { AuthService } from '~/modules/auth/auth.service'
6+
import type { SessionUser } from '~/modules/auth/auth.types'
7+
import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer'
8+
9+
import { BizException } from '../exceptions/biz.exception'
10+
11+
@Injectable()
12+
export class ReaderAuthGuard implements CanActivate {
13+
constructor(private readonly authService: AuthService) {}
14+
15+
async canActivate(context: ExecutionContext): Promise<boolean> {
16+
const request = getNestExecutionContextRequest(context)
17+
18+
const session = await this.authService.getSessionUser(request.raw)
19+
const user = session?.user as SessionUser | undefined
20+
21+
if (!user?.id) {
22+
throw new BizException(ErrorCodeEnum.AuthNotLoggedIn)
23+
}
24+
25+
if (user.role !== 'reader' && user.role !== 'owner') {
26+
throw new BizException(ErrorCodeEnum.AuthNotLoggedIn)
27+
}
28+
29+
request.user = user
30+
request.readerId = user.id
31+
request.isAuthenticated = true
32+
request.hasReaderIdentity = true
33+
request.hasAdminAccess = user.role === 'owner'
34+
request.isGuest = false
35+
36+
Object.assign(request.raw, {
37+
user,
38+
readerId: user.id,
39+
isAuthenticated: true,
40+
hasReaderIdentity: true,
41+
hasAdminAccess: user.role === 'owner',
42+
isGuest: false,
43+
})
44+
45+
return true
46+
}
47+
}

apps/core/src/constants/error-code.constant.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export enum ErrorCodeEnum {
5656
PasswordLoginDisabled = 13006,
5757
AIProviderNotEnabled = 13007,
5858
ImageStorageNotConfigured = 13008,
59+
CommentUploadDisabled = 13009,
5960

6061
// biz - forbidden (403)
6162
NoteForbidden = 14000,
@@ -105,6 +106,15 @@ export enum ErrorCodeEnum {
105106
// biz - config validation (422)
106107
ConfigValidationFailed = 19100,
107108
CannotGetIp = 19101,
109+
CommentUploadFileTooLarge = 19102,
110+
CommentUploadInvalidMime = 19103,
111+
CommentImageCapExceeded = 19104,
112+
CommentUploadFileNotOwned = 19105,
113+
CommentUploadFileAlreadyBound = 19106,
114+
CommentUploadRateLimited = 19107,
115+
CommentUploadQuotaExceeded = 19108,
116+
CommentUploadAccountTooNew = 19109,
117+
CommentUploadInsufficientComments = 19110,
108118

109119
// comment
110120
CommentDisabled = 30000,
@@ -213,6 +223,7 @@ export const ErrorCode = Object.freeze<Record<ErrorCodeEnum, [string, number]>>(
213223
'S3 图床未配置或配置不完整',
214224
400,
215225
],
226+
[ErrorCodeEnum.CommentUploadDisabled]: ['评论图片上传未启用', 503],
216227

217228
// forbidden (403)
218229
[ErrorCodeEnum.NoteForbidden]: ['不要偷看人家的小心思啦~', 403],
@@ -265,6 +276,24 @@ export const ErrorCode = Object.freeze<Record<ErrorCodeEnum, [string, number]>>(
265276
// config validation (422)
266277
[ErrorCodeEnum.ConfigValidationFailed]: ['配置验证失败', 422],
267278
[ErrorCodeEnum.CannotGetIp]: ['无法获取 IP', 422],
279+
[ErrorCodeEnum.CommentUploadFileTooLarge]: ['图片大小超出限制', 413],
280+
[ErrorCodeEnum.CommentUploadInvalidMime]: ['不支持的图片格式', 415],
281+
[ErrorCodeEnum.CommentImageCapExceeded]: ['评论图片数量超过上限', 422],
282+
[ErrorCodeEnum.CommentUploadFileNotOwned]: ['不能引用他人上传的图片', 403],
283+
[ErrorCodeEnum.CommentUploadFileAlreadyBound]: [
284+
'该图片已绑定其他评论,请重新上传',
285+
409,
286+
],
287+
[ErrorCodeEnum.CommentUploadRateLimited]: ['上传过于频繁,请稍后再试', 429],
288+
[ErrorCodeEnum.CommentUploadQuotaExceeded]: ['图片总容量超出限制', 429],
289+
[ErrorCodeEnum.CommentUploadAccountTooNew]: [
290+
'账号注册时间不足,暂无法上传',
291+
403,
292+
],
293+
[ErrorCodeEnum.CommentUploadInsufficientComments]: [
294+
'需累计更多评论后方可上传',
295+
403,
296+
],
268297

269298
[ErrorCodeEnum.MineZip]: ['文件格式必须是 zip 类型', 422],
270299

apps/core/src/modules/comment/comment.controller.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,10 @@ export class CommentController {
382382
updateResult,
383383
)
384384

385+
if (!isUndefined(state)) {
386+
await this.commentService.cascadeFilesForCommentsIfSpam([id], state)
387+
}
388+
385389
return
386390
} catch {
387391
throw new NoContentCanBeModifiedException()
@@ -409,19 +413,30 @@ export class CommentController {
409413
async batchUpdateState(@Body() body: BatchCommentStateDto) {
410414
const { ids, all, state, currentState } = body
411415

416+
let affected: string[] = []
412417
if (all) {
413418
const filter: Record<string, any> = {}
414419
if (!isUndefined(currentState)) {
415420
filter.state = currentState
416421
}
422+
const docs = await this.commentService.model
423+
.find(filter)
424+
.select('_id')
425+
.lean()
426+
affected = docs.map((d) => d._id.toString())
417427
await this.commentService.model.updateMany(filter, { state })
418428
} else if (ids?.length) {
429+
affected = ids.map((id) => id.toString())
419430
await this.commentService.model.updateMany(
420431
{ _id: { $in: ids } },
421432
{ state },
422433
)
423434
}
424435

436+
if (affected.length) {
437+
await this.commentService.cascadeFilesForCommentsIfSpam(affected, state)
438+
}
439+
425440
return
426441
}
427442

apps/core/src/modules/comment/comment.lifecycle.service.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { scheduleManager } from '~/utils/schedule.util'
1515
import { getAvatar } from '~/utils/tool.util'
1616

1717
import { ConfigsService } from '../configs/configs.service'
18+
import { FileDeletionReason } from '../file/file-reference.model'
19+
import { FileReferenceService } from '../file/file-reference.service'
1820
import { OwnerModel } from '../owner/owner.model'
1921
import { OwnerService } from '../owner/owner.service'
2022
import { ReaderService } from '../reader/reader.service'
@@ -53,6 +55,7 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy {
5355
private readonly serverlessService: ServerlessService,
5456
private readonly eventManager: EventManagerService,
5557
private readonly barkService: BarkPushService,
58+
private readonly fileReferenceService: FileReferenceService,
5659
) {}
5760

5861
async onModuleInit() {
@@ -92,10 +95,22 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy {
9295
this.commentCreateListenerDisposer?.()
9396
}
9497

95-
async afterCreateComment(
96-
commentId: string,
97-
ipLocation: { ip: string },
98-
) {
98+
private async cascadeDeleteFilesIfSpamConfigured(commentId: string) {
99+
try {
100+
const config = await this.configsService.get('commentUploadOptions')
101+
if (config.deleteFilesOnSpam === false) return
102+
await this.fileReferenceService.hardDeleteFilesForComment(
103+
commentId,
104+
FileDeletionReason.CommentSpam,
105+
)
106+
} catch (err) {
107+
this.logger.warn(
108+
`cascade file delete after spam(${commentId}) failed: ${err instanceof Error ? err.message : err}`,
109+
)
110+
}
111+
}
112+
113+
async afterCreateComment(commentId: string, ipLocation: { ip: string }) {
99114
const comment = await this.commentModel
100115
.findById(commentId)
101116
.lean({ getters: true })
@@ -121,6 +136,7 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy {
121136
{ _id: commentId },
122137
{ state: CommentState.Junk },
123138
)
139+
await this.cascadeDeleteFilesIfSpamConfigured(commentId)
124140
return
125141
}
126142

@@ -142,10 +158,7 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy {
142158
})
143159
}
144160

145-
async afterReplyComment(
146-
comment: CommentModel,
147-
ipLocation: { ip: string },
148-
) {
161+
async afterReplyComment(comment: CommentModel, ipLocation: { ip: string }) {
149162
const commentId = comment.id ?? (comment as any)._id?.toString()
150163
const isLoggedInComment = !!comment.readerId
151164

@@ -187,7 +200,9 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy {
187200
.then((readers) => readers[0] ?? null)
188201
}
189202

190-
private toOwnerIdentity(ownerInfo: Awaited<ReturnType<OwnerService['getOwnerInfo']>>) {
203+
private toOwnerIdentity(
204+
ownerInfo: Awaited<ReturnType<OwnerService['getOwnerInfo']>>,
205+
) {
191206
return {
192207
role: 'owner' as const,
193208
author: ownerInfo.name || '',
@@ -221,7 +236,9 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy {
221236
author: reader.name || comment.author || '',
222237
mail: reader.email || comment.mail || '',
223238
avatar:
224-
reader.image || comment.avatar || getAvatar(reader.email || comment.mail),
239+
reader.image ||
240+
comment.avatar ||
241+
getAvatar(reader.email || comment.mail),
225242
}
226243
}
227244
}
@@ -257,14 +274,20 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy {
257274
const parentIdentity = await this.resolveCommentIdentity(parent, ownerInfo)
258275

259276
if (!refDoc || !ownerInfo.mail) return
260-
if (type === CommentReplyMailType.Guest && commentIdentity.role === 'guest') {
277+
if (
278+
type === CommentReplyMailType.Guest &&
279+
commentIdentity.role === 'guest'
280+
) {
261281
commentIdentity =
262282
!comment.author && !comment.mail && !comment.avatar
263283
? this.toOwnerIdentity(ownerInfo)
264284
: commentIdentity
265285
}
266286

267-
if (type === CommentReplyMailType.Owner && commentIdentity.role === 'owner') {
287+
if (
288+
type === CommentReplyMailType.Owner &&
289+
commentIdentity.role === 'owner'
290+
) {
268291
return
269292
}
270293

0 commit comments

Comments
 (0)