Skip to content

Commit ca1e328

Browse files
authored
feat(link): convert external friend avatar links to internal links (#2480)
* feat(link): convert external friend links to internal links * chore(test): add test for link * fix(link): remove overly strict file type matcher * chore: typo
1 parent 3c65069 commit ca1e328

File tree

8 files changed

+386
-17
lines changed

8 files changed

+386
-17
lines changed

apps/core/src/modules/configs/configs.default.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ export const generateDefaultConfig: () => IConfig = () => ({
4343
enableComment: true,
4444
enableThrottleGuard: false,
4545
},
46-
friendLinkOptions: { allowApply: true, allowSubPath: false },
46+
friendLinkOptions: {
47+
allowApply: true,
48+
allowSubPath: false,
49+
enableAvatarInternalization: true,
50+
},
4751
backupOptions: {
4852
enable: true,
4953
endpoint: null!,

apps/core/src/modules/configs/configs.dto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,14 @@ export class FriendLinkOptionsDto {
326326
@IsOptional()
327327
@JSONSchemaToggleField('允许子路径友链', { description: '例如 /blog 子路径' })
328328
allowSubPath: boolean
329+
330+
@IsBoolean()
331+
@IsOptional()
332+
@JSONSchemaToggleField('友链头像转内链', {
333+
description:
334+
'通过审核后将会下载友链头像并改为内部链接,仅支持常见图片格式,其他格式将不会转换',
335+
})
336+
enableAvatarInternalization: boolean
329337
}
330338

331339
@JSONSchema({ title: '文本设定' })
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { Readable } from 'node:stream'
2+
import { URL } from 'node:url'
3+
import { nanoid } from '@mx-space/compiled'
4+
import {
5+
BadRequestException,
6+
Injectable,
7+
Logger,
8+
NotFoundException,
9+
} from '@nestjs/common'
10+
import type { DocumentType } from '@typegoose/typegoose'
11+
import { alphabet } from '~/constants/other.constant'
12+
import { HttpService } from '~/processors/helper/helper.http.service'
13+
import { InjectModel } from '~/transformers/model.transformer'
14+
import { validateImageBuffer } from '~/utils/image.util'
15+
import { ConfigsService } from '../configs/configs.service'
16+
import { FileService } from '../file/file.service'
17+
import type { FileType } from '../file/file.type'
18+
import { LinkModel, LinkState } from './link.model'
19+
20+
const { customAlphabet } = nanoid
21+
const AVATAR_TYPE: FileType = 'avatar'
22+
23+
const ALLOWED_IMAGE_MIME_TYPES = new Set([
24+
'image/jpeg',
25+
'image/jpg',
26+
'image/png',
27+
'image/webp',
28+
'image/gif',
29+
'image/x-icon',
30+
])
31+
32+
const ALLOWED_IMAGE_EXTENSIONS = new Set([
33+
'.jpg',
34+
'.jpeg',
35+
'.png',
36+
'.webp',
37+
'.gif',
38+
'.ico',
39+
])
40+
41+
@Injectable()
42+
export class LinkAvatarService {
43+
private readonly logger: Logger
44+
45+
constructor(
46+
@InjectModel(LinkModel)
47+
private readonly linkModel: MongooseModel<LinkModel>,
48+
private readonly configsService: ConfigsService,
49+
private readonly fileService: FileService,
50+
private readonly http: HttpService,
51+
) {
52+
this.logger = new Logger(LinkAvatarService.name)
53+
}
54+
55+
async convertToInternal(
56+
link: string | DocumentType<LinkModel>,
57+
): Promise<boolean> {
58+
const doc =
59+
typeof link === 'string' ? await this.linkModel.findById(link) : link
60+
if (!doc) {
61+
if (typeof link === 'string') {
62+
throw new NotFoundException()
63+
}
64+
return false
65+
}
66+
67+
const avatar = doc.avatar
68+
if (!avatar || !this.isExternalAvatar(avatar)) {
69+
return false
70+
}
71+
72+
const { url: configUrl, friendLinkOptions } =
73+
await this.configsService.waitForConfigReady()
74+
75+
if (!friendLinkOptions.enableAvatarInternalization) {
76+
return false
77+
}
78+
79+
const { webUrl } = configUrl
80+
81+
const refererHeader = (() => {
82+
try {
83+
if (doc.url) {
84+
return new URL(doc.url).origin
85+
}
86+
} catch (error: any) {
87+
this.logger.warn(
88+
`解析友链 ${doc._id} 的站点地址失败: ${error?.message || String(error)}`,
89+
)
90+
}
91+
return webUrl
92+
})()
93+
94+
const response = await this.http.axiosRef.get<ArrayBuffer>(avatar, {
95+
responseType: 'arraybuffer',
96+
timeout: 10_000,
97+
headers: {
98+
'user-agent':
99+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
100+
referer: refererHeader,
101+
},
102+
})
103+
104+
const buffer = Buffer.from(response.data as any)
105+
const contentType = (response.headers['content-type'] ||
106+
response.headers['Content-Type']) as string | undefined
107+
const normalizedContentType = this.normalizeMimeType(contentType)
108+
109+
if (
110+
!normalizedContentType ||
111+
!this.isAllowedMimeType(normalizedContentType)
112+
) {
113+
this.logger.warn(
114+
`友链 ${doc._id} 头像响应类型 ${contentType || 'unknown'} 不在受支持图片范围,跳过内链转换`,
115+
)
116+
return false
117+
}
118+
119+
const validation = validateImageBuffer({
120+
originUrl: avatar,
121+
buffer,
122+
allowedExtensions: ALLOWED_IMAGE_EXTENSIONS,
123+
allowedMimeTypes: ALLOWED_IMAGE_MIME_TYPES,
124+
})
125+
126+
if (!validation.ok) {
127+
throw new BadRequestException(validation.reason)
128+
}
129+
130+
const { ext } = validation
131+
132+
const filename = customAlphabet(alphabet)(18) + ext.toLowerCase()
133+
134+
await this.fileService.writeFile(
135+
AVATAR_TYPE,
136+
filename,
137+
Readable.from(buffer),
138+
)
139+
140+
const internalUrl = await this.fileService.resolveFileUrl(
141+
AVATAR_TYPE,
142+
filename,
143+
)
144+
145+
doc.avatar = internalUrl
146+
await doc.save()
147+
148+
this.logger.log(`友链 ${doc._id} 头像已转换为内部链接`)
149+
150+
return true
151+
}
152+
153+
async migratePassedLinks(): Promise<{
154+
updatedCount: number
155+
updatedIds: string[]
156+
}> {
157+
const { friendLinkOptions } = await this.configsService.waitForConfigReady()
158+
if (!friendLinkOptions.enableAvatarInternalization) {
159+
return {
160+
updatedCount: 0,
161+
updatedIds: [],
162+
}
163+
}
164+
165+
const links = await this.linkModel
166+
.find({
167+
state: LinkState.Pass,
168+
avatar: { $exists: true, $ne: null },
169+
})
170+
.lean()
171+
172+
const updatedIds: string[] = []
173+
174+
for (const link of links) {
175+
try {
176+
if (this.isExternalAvatar(link.avatar as string)) {
177+
const converted = await this.convertToInternal(String(link._id))
178+
if (converted) {
179+
updatedIds.push(String(link._id))
180+
}
181+
}
182+
} catch (error: any) {
183+
this.logger.error(
184+
`迁移友链头像失败: ${link._id} - ${error?.message || String(error)}`,
185+
)
186+
}
187+
}
188+
189+
return {
190+
updatedCount: updatedIds.length,
191+
updatedIds,
192+
}
193+
}
194+
195+
private isExternalAvatar(avatar: string | undefined | null): boolean {
196+
if (!avatar) return false
197+
try {
198+
new URL(avatar)
199+
} catch {
200+
return false
201+
}
202+
if (avatar.includes('/objects/avatar/')) {
203+
return false
204+
}
205+
206+
return true
207+
}
208+
209+
private normalizeMimeType(mime?: string): string | undefined {
210+
if (!mime) return undefined
211+
return mime.split(';')[0].trim().toLowerCase()
212+
}
213+
214+
private isAllowedMimeType(mime: string): boolean {
215+
return ALLOWED_IMAGE_MIME_TYPES.has(mime)
216+
}
217+
}

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,17 @@ export class LinkController {
109109
@Patch('/audit/:id')
110110
@Auth()
111111
async approveLink(@Param('id') id: string) {
112-
const doc = await this.linkService.approveLink(id)
112+
const { link, convertedAvatar } = await this.linkService.approveLink(id)
113113

114114
scheduleManager.schedule(async () => {
115-
if (doc.email) {
116-
await this.linkService.sendToCandidate(doc)
115+
if (link.email) {
116+
await this.linkService.sendToCandidate(link as any)
117117
}
118118
})
119-
return
119+
return {
120+
link,
121+
convertedAvatar,
122+
}
120123
}
121124

122125
@Post('/audit/reason/:id')
@@ -136,4 +139,11 @@ export class LinkController {
136139
async checkHealth() {
137140
return this.linkService.checkLinkHealth()
138141
}
142+
143+
/** 批量迁移已通过友链的外部头像为内部链接 */
144+
@Post('/avatar/migrate')
145+
@Auth()
146+
async migrateExternalAvatars() {
147+
return this.linkService.migrateExternalAvatarsForPassedLinks()
148+
}
139149
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Module } from '@nestjs/common'
22
import { GatewayModule } from '~/processors/gateway/gateway.module'
3+
import { FileModule } from '../file/file.module'
4+
import { LinkAvatarService } from './link-avatar.service'
35
import { LinkController, LinkControllerCrud } from './link.controller'
46
import { LinkService } from './link.service'
57

68
@Module({
79
controllers: [LinkController, LinkControllerCrud],
8-
providers: [LinkService],
10+
providers: [LinkService, LinkAvatarService],
911
exports: [LinkService],
10-
imports: [GatewayModule],
12+
imports: [GatewayModule, FileModule],
1113
})
1214
export class LinkModule {}

apps/core/src/modules/link/link.service.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { InjectModel } from '~/transformers/model.transformer'
1515
import { scheduleManager } from '~/utils/schedule.util'
1616
import { ConfigsService } from '../configs/configs.service'
1717
import { UserService } from '../user/user.service'
18+
import { LinkAvatarService } from './link-avatar.service'
1819
import { LinkApplyEmailType } from './link-mail.enum'
1920
import { LinkModel, LinkState, LinkStateMap, LinkType } from './link.model'
2021

@@ -30,6 +31,7 @@ export class LinkService {
3031
private readonly eventManager: EventManagerService,
3132
private readonly http: HttpService,
3233
private readonly configsService: ConfigsService,
34+
private readonly linkAvatarService: LinkAvatarService,
3335
) {}
3436

3537
public get model() {
@@ -91,20 +93,24 @@ export class LinkService {
9193
}
9294

9395
async approveLink(id: string) {
94-
const doc = await this.model
95-
.findOneAndUpdate(
96-
{ _id: id },
97-
{
98-
$set: { state: LinkState.Pass },
99-
},
100-
)
101-
.lean()
96+
const doc = await this.model.findOneAndUpdate(
97+
{ _id: id },
98+
{
99+
$set: { state: LinkState.Pass },
100+
},
101+
{ new: true },
102+
)
102103

103104
if (!doc) {
104105
throw new NotFoundException()
105106
}
106107

107-
return doc
108+
const convertedAvatar = await this.linkAvatarService.convertToInternal(doc)
109+
110+
return {
111+
link: doc.toObject(),
112+
convertedAvatar,
113+
}
108114
}
109115

110116
async getCount() {
@@ -284,4 +290,8 @@ export class LinkService {
284290
text: `申请结果:${LinkStateMap[state]}\n原因:${reason}`,
285291
})
286292
}
293+
294+
async migrateExternalAvatarsForPassedLinks() {
295+
return this.linkAvatarService.migratePassedLinks()
296+
}
287297
}

0 commit comments

Comments
 (0)