twikoo-func 1.7.15 → 1.7.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +96 -2
- package/package.json +7 -5
- package/utils/image.js +89 -39
- package/utils/index.js +1 -2
- package/utils/lib.js +0 -8
- package/utils/notify.js +21 -9
- package/utils/spam.js +16 -1
package/index.js
CHANGED
|
@@ -249,11 +249,30 @@ function getAdminTicket (credentials) {
|
|
|
249
249
|
return ticket
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
function getSearchKeyword (event) {
|
|
253
|
+
if (event.keyword === undefined || event.keyword === null) return ''
|
|
254
|
+
if (typeof event.keyword !== 'string') throw new Error('搜索关键词必须是字符串')
|
|
255
|
+
const keyword = event.keyword.trim()
|
|
256
|
+
if (keyword.length > 100) throw new Error('搜索关键词不能超过 100 个字符')
|
|
257
|
+
return keyword
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function commentMatchesKeyword (comment, keyword) {
|
|
261
|
+
return [comment.nick, comment.comment].some(value =>
|
|
262
|
+
typeof value === 'string' && value.toLowerCase().includes(keyword)
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function escapeRegExp (value) {
|
|
267
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
268
|
+
}
|
|
269
|
+
|
|
252
270
|
// 读取评论
|
|
253
271
|
async function commentGet (event) {
|
|
254
272
|
const res = {}
|
|
255
273
|
try {
|
|
256
274
|
validate(event, ['url'])
|
|
275
|
+
if (getSearchKeyword(event)) return commentSearch(event)
|
|
257
276
|
const uid = await auth.getEndUserInfo().userInfo.uid
|
|
258
277
|
const isAdminUser = await isAdmin()
|
|
259
278
|
const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8
|
|
@@ -340,6 +359,80 @@ async function commentGet (event) {
|
|
|
340
359
|
return res
|
|
341
360
|
}
|
|
342
361
|
|
|
362
|
+
async function commentSearch (event) {
|
|
363
|
+
const res = {}
|
|
364
|
+
try {
|
|
365
|
+
validate(event, ['url'])
|
|
366
|
+
const keyword = getSearchKeyword(event).toLowerCase()
|
|
367
|
+
const page = Math.max(parseInt(event.page) || 1, 1)
|
|
368
|
+
const uid = await auth.getEndUserInfo().userInfo.uid
|
|
369
|
+
const isAdminUser = await isAdmin()
|
|
370
|
+
const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8
|
|
371
|
+
const sort = event.sort || 'newest'
|
|
372
|
+
let more = false
|
|
373
|
+
|
|
374
|
+
const condition = { url: _.in(getUrlQuery(event.url)) }
|
|
375
|
+
const query = getCommentQuery({ condition, uid, isAdminUser })
|
|
376
|
+
const searchComments = []
|
|
377
|
+
let cursor
|
|
378
|
+
while (true) {
|
|
379
|
+
const batchQuery = cursor
|
|
380
|
+
? _.and(query, _.or(
|
|
381
|
+
{ created: _.gt(cursor.created) },
|
|
382
|
+
{ created: cursor.created, _id: _.gt(cursor._id) }
|
|
383
|
+
))
|
|
384
|
+
: query
|
|
385
|
+
const batch = await db.collection('comment')
|
|
386
|
+
.where(batchQuery)
|
|
387
|
+
.orderBy('created', 'asc')
|
|
388
|
+
.orderBy('_id', 'asc')
|
|
389
|
+
.limit(100)
|
|
390
|
+
.get()
|
|
391
|
+
searchComments.push(...batch.data)
|
|
392
|
+
if (batch.data.length < 100) break
|
|
393
|
+
cursor = batch.data[batch.data.length - 1]
|
|
394
|
+
}
|
|
395
|
+
const matchedRoots = new Set(searchComments
|
|
396
|
+
.filter(comment => commentMatchesKeyword(comment, keyword))
|
|
397
|
+
.map(comment => String(comment.rid || comment._id)))
|
|
398
|
+
let main = searchComments.filter(comment =>
|
|
399
|
+
(!comment.rid || comment.rid === '') && matchedRoots.has(String(comment._id))
|
|
400
|
+
)
|
|
401
|
+
const count = main.length
|
|
402
|
+
|
|
403
|
+
main.sort((a, b) => {
|
|
404
|
+
if (sort === 'oldest') return a.created - b.created
|
|
405
|
+
if (sort === 'popular') {
|
|
406
|
+
const ups = (b.ups || []).length - (a.ups || []).length
|
|
407
|
+
if (ups) return ups
|
|
408
|
+
}
|
|
409
|
+
return b.created - a.created
|
|
410
|
+
})
|
|
411
|
+
let top = []
|
|
412
|
+
if (!config.TOP_DISABLED) {
|
|
413
|
+
if (page === 1) top = main.filter(comment => comment.top === true)
|
|
414
|
+
main = main.filter(comment => comment.top !== true)
|
|
415
|
+
}
|
|
416
|
+
main = main.slice((page - 1) * limit, page * limit + 1)
|
|
417
|
+
|
|
418
|
+
if (main.length > limit) {
|
|
419
|
+
more = true
|
|
420
|
+
main = main.slice(0, limit)
|
|
421
|
+
}
|
|
422
|
+
main = [...top, ...main]
|
|
423
|
+
|
|
424
|
+
const mainIds = new Set(main.map(comment => String(comment._id)))
|
|
425
|
+
const reply = searchComments.filter(comment => mainIds.has(String(comment.rid)))
|
|
426
|
+
res.data = parseComment([...main, ...reply], uid, config)
|
|
427
|
+
res.more = more
|
|
428
|
+
res.count = count
|
|
429
|
+
} catch (e) {
|
|
430
|
+
res.data = []
|
|
431
|
+
res.message = e.message
|
|
432
|
+
}
|
|
433
|
+
return res
|
|
434
|
+
}
|
|
435
|
+
|
|
343
436
|
function getCommentQuery ({ condition, uid, isAdminUser }) {
|
|
344
437
|
return _.or(
|
|
345
438
|
{ ...condition, isSpam: _.neq(isAdminUser ? 'imaegoo' : true) },
|
|
@@ -387,9 +480,10 @@ function getCommentSearchCondition (event) {
|
|
|
387
480
|
break
|
|
388
481
|
}
|
|
389
482
|
}
|
|
390
|
-
|
|
483
|
+
const keyword = getSearchKeyword(event)
|
|
484
|
+
if (keyword) {
|
|
391
485
|
const regExp = new db.RegExp({
|
|
392
|
-
regexp:
|
|
486
|
+
regexp: escapeRegExp(keyword),
|
|
393
487
|
options: 'i'
|
|
394
488
|
})
|
|
395
489
|
condition = _.or(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twikoo-func",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.17",
|
|
4
4
|
"description": "A simple comment system.",
|
|
5
5
|
"author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://twikoo.js.org",
|
|
13
13
|
"dependencies": {
|
|
14
|
+
"@xsai/generate-text": "0.2.2",
|
|
15
|
+
"@xsai/shared": "0.2.2",
|
|
16
|
+
"@xsai/shared-chat": "0.2.2",
|
|
14
17
|
"@cap.js/server": "^4.0.5",
|
|
15
18
|
"@cloudbase/manager-node": "^3.9.0",
|
|
16
19
|
"@cloudbase/node-sdk": "^2.5.0",
|
|
@@ -21,14 +24,13 @@
|
|
|
21
24
|
"bowser": "^2.11.0",
|
|
22
25
|
"cheerio": "1.0.0-rc.5",
|
|
23
26
|
"crypto-js": "^4.0.0",
|
|
24
|
-
"dompurify": "^2.
|
|
27
|
+
"dompurify": "^2.5.9",
|
|
25
28
|
"form-data": "^4.0.0",
|
|
26
29
|
"jsdom": "^16.4.0",
|
|
27
30
|
"marked": "^4.0.12",
|
|
28
|
-
"nodemailer": "^
|
|
31
|
+
"nodemailer": "^9.0.5",
|
|
29
32
|
"pushoo": "latest",
|
|
30
33
|
"tencentcloud-sdk-nodejs": "^4.0.65",
|
|
31
|
-
"xml2js": "^0.6.0"
|
|
32
|
-
"xsai": "^0.2.2"
|
|
34
|
+
"xml2js": "^0.6.0"
|
|
33
35
|
}
|
|
34
36
|
}
|
package/utils/image.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
const
|
|
2
|
-
const os = require('os')
|
|
3
|
-
const path = require('path')
|
|
1
|
+
const crypto = require('crypto')
|
|
4
2
|
const { isUrl } = require('.')
|
|
5
3
|
const { RES_CODE } = require('./constants')
|
|
6
4
|
const { getAxios, getFormData } = require('./lib')
|
|
@@ -8,9 +6,33 @@ const axios = getAxios()
|
|
|
8
6
|
const FormData = getFormData()
|
|
9
7
|
const logger = require('./logger')
|
|
10
8
|
|
|
9
|
+
const MAX_IMAGE_SIZE = 10 * 1024 * 1024
|
|
10
|
+
const IMAGE_TYPES = [
|
|
11
|
+
{
|
|
12
|
+
mimeType: 'image/jpeg',
|
|
13
|
+
extension: 'jpg',
|
|
14
|
+
matches: (body) => body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
mimeType: 'image/png',
|
|
18
|
+
extension: 'png',
|
|
19
|
+
matches: (body) => body.length >= 8 && body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
mimeType: 'image/gif',
|
|
23
|
+
extension: 'gif',
|
|
24
|
+
matches: (body) => body.length >= 6 && (body.subarray(0, 6).equals(Buffer.from('GIF87a')) || body.subarray(0, 6).equals(Buffer.from('GIF89a')))
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
mimeType: 'image/webp',
|
|
28
|
+
extension: 'webp',
|
|
29
|
+
matches: (body) => body.length >= 12 && body.subarray(0, 4).equals(Buffer.from('RIFF')) && body.subarray(8, 12).equals(Buffer.from('WEBP'))
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
|
|
11
33
|
const fn = {
|
|
12
34
|
async uploadImage (event, config) {
|
|
13
|
-
const { photo
|
|
35
|
+
const { photo } = event
|
|
14
36
|
const res = {}
|
|
15
37
|
const imageService = config.IMAGE_CDN
|
|
16
38
|
try {
|
|
@@ -22,8 +44,9 @@ const fn = {
|
|
|
22
44
|
} else if (!imageService || !config.IMAGE_CDN_TOKEN) {
|
|
23
45
|
throw new Error('未配置图片上传服务')
|
|
24
46
|
}
|
|
47
|
+
const image = fn.parseImage(photo)
|
|
25
48
|
if (config.NSFW_API_URL) {
|
|
26
|
-
const nsfwResult = await fn.checkNsfw({
|
|
49
|
+
const nsfwResult = await fn.checkNsfw({ image, config })
|
|
27
50
|
if (nsfwResult.rejected) {
|
|
28
51
|
res.code = RES_CODE.NSFW_REJECTED
|
|
29
52
|
res.err = nsfwResult.message
|
|
@@ -32,21 +55,21 @@ const fn = {
|
|
|
32
55
|
}
|
|
33
56
|
// tip: qcloud 图床走前端上传,其他图床走后端上传
|
|
34
57
|
if (imageService === '7bu') {
|
|
35
|
-
await fn.uploadImageToLskyPro({
|
|
58
|
+
await fn.uploadImageToLskyPro({ image, config, res, imageCdn: 'https://7bu.top' })
|
|
36
59
|
} else if (imageService === 'see') {
|
|
37
|
-
await fn.uploadImageToSee({
|
|
60
|
+
await fn.uploadImageToSee({ image, config, res, imageCdn: 'https://s.ee/api/v1/file/upload' })
|
|
38
61
|
} else if (isUrl(imageService)) {
|
|
39
|
-
await fn.uploadImageToLskyPro({
|
|
62
|
+
await fn.uploadImageToLskyPro({ image, config, res, imageCdn: imageService })
|
|
40
63
|
} else if (imageService === 'lskypro') {
|
|
41
|
-
await fn.uploadImageToLskyPro({
|
|
64
|
+
await fn.uploadImageToLskyPro({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
|
|
42
65
|
} else if (imageService === 'piclist') {
|
|
43
|
-
await fn.uploadImageToPicList({
|
|
66
|
+
await fn.uploadImageToPicList({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
|
|
44
67
|
} else if (imageService === 'easyimage') {
|
|
45
|
-
await fn.uploadImageToEasyImage({
|
|
68
|
+
await fn.uploadImageToEasyImage({ image, config, res })
|
|
46
69
|
} else if (imageService === 'chevereto') {
|
|
47
|
-
await fn.uploadImageToChevereto({
|
|
70
|
+
await fn.uploadImageToChevereto({ image, config, res })
|
|
48
71
|
} else if (imageService === 's3') {
|
|
49
|
-
await fn.uploadImageToS3({
|
|
72
|
+
await fn.uploadImageToS3({ image, config, res })
|
|
50
73
|
} else {
|
|
51
74
|
throw new Error('不支持的图片上传服务')
|
|
52
75
|
}
|
|
@@ -57,13 +80,13 @@ const fn = {
|
|
|
57
80
|
}
|
|
58
81
|
return res
|
|
59
82
|
},
|
|
60
|
-
async checkNsfw ({
|
|
83
|
+
async checkNsfw ({ image, config }) {
|
|
61
84
|
const result = { rejected: false, message: '' }
|
|
62
85
|
try {
|
|
63
86
|
const threshold = parseFloat(config.NSFW_THRESHOLD) || 0.5
|
|
64
87
|
const apiUrl = config.NSFW_API_URL.replace(/\/$/, '')
|
|
65
88
|
const formData = new FormData()
|
|
66
|
-
|
|
89
|
+
fn.appendImage(formData, 'image', image)
|
|
67
90
|
const response = await axios.post(`${apiUrl}/classify`, formData, {
|
|
68
91
|
headers: {
|
|
69
92
|
...formData.getHeaders()
|
|
@@ -84,10 +107,10 @@ const fn = {
|
|
|
84
107
|
}
|
|
85
108
|
return result
|
|
86
109
|
},
|
|
87
|
-
async uploadImageToSee ({
|
|
110
|
+
async uploadImageToSee ({ image, config, res, imageCdn }) {
|
|
88
111
|
// S.EE 图床 https://s.ee (原 SM.MS)
|
|
89
112
|
const formData = new FormData()
|
|
90
|
-
|
|
113
|
+
fn.appendImage(formData, 'smfile', image)
|
|
91
114
|
const uploadResult = await axios.post(imageCdn, formData, {
|
|
92
115
|
headers: {
|
|
93
116
|
...formData.getHeaders(),
|
|
@@ -100,10 +123,10 @@ const fn = {
|
|
|
100
123
|
throw new Error(uploadResult.data.message)
|
|
101
124
|
}
|
|
102
125
|
},
|
|
103
|
-
async uploadImageToLskyPro ({
|
|
126
|
+
async uploadImageToLskyPro ({ image, config, res, imageCdn }) {
|
|
104
127
|
// 自定义兰空图床(v2)URL
|
|
105
128
|
const formData = new FormData()
|
|
106
|
-
|
|
129
|
+
fn.appendImage(formData, 'file', image)
|
|
107
130
|
if (process.env.TWIKOO_LSKY_STRATEGY_ID) {
|
|
108
131
|
formData.append('strategy_id', parseInt(process.env.TWIKOO_LSKY_STRATEGY_ID))
|
|
109
132
|
}
|
|
@@ -125,11 +148,11 @@ const fn = {
|
|
|
125
148
|
throw new Error(uploadResult.data.message)
|
|
126
149
|
}
|
|
127
150
|
},
|
|
128
|
-
async uploadImageToPicList ({
|
|
151
|
+
async uploadImageToPicList ({ image, config, res, imageCdn }) {
|
|
129
152
|
// PicList https://piclist.cn/ 高效的云存储和图床平台管理工具
|
|
130
153
|
// 鉴权使用 query 参数 key
|
|
131
154
|
const formData = new FormData()
|
|
132
|
-
|
|
155
|
+
fn.appendImage(formData, 'file', image)
|
|
133
156
|
let url = `${imageCdn}/upload`
|
|
134
157
|
// 如果填写了 key 则拼接 url
|
|
135
158
|
if (config.IMAGE_CDN_TOKEN) {
|
|
@@ -143,7 +166,7 @@ const fn = {
|
|
|
143
166
|
throw new Error(uploadResult.data.message)
|
|
144
167
|
}
|
|
145
168
|
},
|
|
146
|
-
async uploadImageToEasyImage ({
|
|
169
|
+
async uploadImageToEasyImage ({ image, config, res }) {
|
|
147
170
|
// EasyImage2.0 https://github.com/icret/EasyImages2.0 简单图床 - 一款功能强大无数据库的图床 2.0版
|
|
148
171
|
try {
|
|
149
172
|
// 参数校验
|
|
@@ -158,9 +181,7 @@ const fn = {
|
|
|
158
181
|
// 添加 token 参数到 Body
|
|
159
182
|
formData.append('token', config.IMAGE_CDN_TOKEN)
|
|
160
183
|
// 添加图片文件(固定参数名 image)
|
|
161
|
-
|
|
162
|
-
filename: fileName
|
|
163
|
-
})
|
|
184
|
+
fn.appendImage(formData, 'image', image)
|
|
164
185
|
// 发送请求
|
|
165
186
|
const uploadResult = await axios.post(config.IMAGE_CDN_URL, formData, {
|
|
166
187
|
headers: {
|
|
@@ -193,7 +214,7 @@ const fn = {
|
|
|
193
214
|
throw new Error(errorMsg)
|
|
194
215
|
}
|
|
195
216
|
},
|
|
196
|
-
async uploadImageToChevereto ({
|
|
217
|
+
async uploadImageToChevereto ({ image, config, res }) {
|
|
197
218
|
if (!config.IMAGE_CDN_URL) {
|
|
198
219
|
throw new Error('未配置 Chevereto 站点地址 (IMAGE_CDN_URL)')
|
|
199
220
|
}
|
|
@@ -202,7 +223,7 @@ const fn = {
|
|
|
202
223
|
}
|
|
203
224
|
const formData = new FormData()
|
|
204
225
|
formData.append('key', config.IMAGE_CDN_TOKEN)
|
|
205
|
-
|
|
226
|
+
fn.appendImage(formData, 'source', image)
|
|
206
227
|
formData.append('format', 'json')
|
|
207
228
|
const apiUrl = config.IMAGE_CDN_URL.replace(/\/$/, '') + '/api/1/upload'
|
|
208
229
|
const uploadResult = await axios.post(apiUrl, formData, {
|
|
@@ -222,7 +243,7 @@ const fn = {
|
|
|
222
243
|
throw new Error(`Chevereto 上传失败: ${errMsg}`)
|
|
223
244
|
}
|
|
224
245
|
},
|
|
225
|
-
async uploadImageToS3 ({
|
|
246
|
+
async uploadImageToS3 ({ image, config, res }) {
|
|
226
247
|
// 使用原生 crypto + axios 实现 AWS Signature V4,无需引入 SDK
|
|
227
248
|
if (!config.S3_BUCKET) {
|
|
228
249
|
throw new Error('未配置 S3 存储桶名称 (S3_BUCKET)')
|
|
@@ -233,15 +254,11 @@ const fn = {
|
|
|
233
254
|
if (!config.S3_SECRET_ACCESS_KEY) {
|
|
234
255
|
throw new Error('未配置 S3 Secret Access Key (S3_SECRET_ACCESS_KEY)')
|
|
235
256
|
}
|
|
236
|
-
const crypto = require('crypto')
|
|
237
257
|
const region = config.S3_REGION || 'us-east-1'
|
|
238
|
-
|
|
239
|
-
const base64 = photo.split(';base64,').pop()
|
|
240
|
-
const mimeType = photo.split(';base64,')[0].replace('data:', '') || 'image/webp'
|
|
241
|
-
const body = Buffer.from(base64, 'base64')
|
|
258
|
+
const { body, mimeType, fileName } = image
|
|
242
259
|
// 构建对象 key
|
|
243
260
|
const prefix = config.S3_PATH_PREFIX ? config.S3_PATH_PREFIX.replace(/\/$/, '') + '/' : ''
|
|
244
|
-
const key = `${prefix}${
|
|
261
|
+
const key = `${prefix}${fileName}`
|
|
245
262
|
const forcePathStyle = String(config.S3_FORCE_PATH_STYLE).trim().toLowerCase() !== 'false'
|
|
246
263
|
let endpoint
|
|
247
264
|
let s3Base
|
|
@@ -315,11 +332,44 @@ const fn = {
|
|
|
315
332
|
}
|
|
316
333
|
res.data = { url: fileUrl }
|
|
317
334
|
},
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
335
|
+
parseImage (photo) {
|
|
336
|
+
if (typeof photo !== 'string' || photo.length > Math.ceil(MAX_IMAGE_SIZE / 3) * 4 + 64) {
|
|
337
|
+
throw new Error('图片大小不能超过 10 MB')
|
|
338
|
+
}
|
|
339
|
+
const header = /^data:([a-z0-9.+-]+\/[a-z0-9.+-]+);base64,/i.exec(photo)
|
|
340
|
+
if (!header) {
|
|
341
|
+
throw new Error('图片数据格式不合法')
|
|
342
|
+
}
|
|
343
|
+
const declaredMimeType = header[1].toLowerCase()
|
|
344
|
+
const base64 = photo.slice(header[0].length)
|
|
345
|
+
if (!base64 || base64.length % 4 !== 0 || !/^[a-z0-9+/]*={0,2}$/i.test(base64)) {
|
|
346
|
+
throw new Error('图片数据格式不合法')
|
|
347
|
+
}
|
|
348
|
+
const padding = base64.endsWith('==') ? 2 : (base64.endsWith('=') ? 1 : 0)
|
|
349
|
+
const decodedSize = base64.length * 3 / 4 - padding
|
|
350
|
+
if (decodedSize > MAX_IMAGE_SIZE) {
|
|
351
|
+
throw new Error('图片大小不能超过 10 MB')
|
|
352
|
+
}
|
|
353
|
+
const body = Buffer.from(base64, 'base64')
|
|
354
|
+
const imageType = IMAGE_TYPES.find((type) => type.matches(body))
|
|
355
|
+
if (!imageType) {
|
|
356
|
+
throw new Error('仅支持 JPEG、PNG、GIF 和 WebP 图片')
|
|
357
|
+
}
|
|
358
|
+
if (declaredMimeType !== imageType.mimeType) {
|
|
359
|
+
throw new Error('图片 MIME 类型与文件内容不匹配')
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
body,
|
|
363
|
+
mimeType: imageType.mimeType,
|
|
364
|
+
fileName: `${crypto.randomBytes(16).toString('hex')}.${imageType.extension}`
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
appendImage (formData, fieldName, image) {
|
|
368
|
+
formData.append(fieldName, image.body, {
|
|
369
|
+
filename: image.fileName,
|
|
370
|
+
contentType: image.mimeType,
|
|
371
|
+
knownLength: image.body.length
|
|
372
|
+
})
|
|
323
373
|
}
|
|
324
374
|
}
|
|
325
375
|
|
package/utils/index.js
CHANGED
|
@@ -418,8 +418,7 @@ const fn = {
|
|
|
418
418
|
HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
|
|
419
419
|
HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
|
|
420
420
|
LIMIT_LENGTH: config.LIMIT_LENGTH,
|
|
421
|
-
CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER
|
|
422
|
-
QQ_API_KEY: config.QQ_API_KEY
|
|
421
|
+
CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER
|
|
423
422
|
}
|
|
424
423
|
|
|
425
424
|
// 仅在明确指定使用 Turnstile 时下发 Turnstile 的 site key
|
package/utils/lib.js
CHANGED
|
@@ -71,13 +71,5 @@ module.exports = {
|
|
|
71
71
|
getXml2js () {
|
|
72
72
|
const xml2js = require('xml2js') // XML 解析
|
|
73
73
|
return xml2js
|
|
74
|
-
},
|
|
75
|
-
getOpenAIClient (config) {
|
|
76
|
-
const { createXSClient } = require('xsai') // xsai SDK
|
|
77
|
-
const openaiClient = createXSClient({
|
|
78
|
-
apiKey: config.LLM_API_KEY,
|
|
79
|
-
baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1'
|
|
80
|
-
})
|
|
81
|
-
return openaiClient
|
|
82
74
|
}
|
|
83
75
|
}
|
package/utils/notify.js
CHANGED
|
@@ -9,6 +9,18 @@ const pushoo = getPushoo()
|
|
|
9
9
|
const { RES_CODE } = require('./constants')
|
|
10
10
|
const logger = require('./logger')
|
|
11
11
|
|
|
12
|
+
// HTML 实体转义,防止用户可控字段(昵称、邮箱等)在邮件 HTML 模板中造成存储型 XSS
|
|
13
|
+
// 仅在渲染邮件时转义,不修改数据库的存储内容
|
|
14
|
+
function escapeHtml (str) {
|
|
15
|
+
if (typeof str !== 'string') return str
|
|
16
|
+
return str
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''')
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
let nodemailer
|
|
13
25
|
|
|
14
26
|
function lazilyGetNodemailer () {
|
|
@@ -85,13 +97,13 @@ const fn = {
|
|
|
85
97
|
return
|
|
86
98
|
}
|
|
87
99
|
const SITE_NAME = config.SITE_NAME
|
|
88
|
-
const NICK = comment.nick
|
|
100
|
+
const NICK = escapeHtml(comment.nick)
|
|
89
101
|
const IMG = getAvatar(comment, config)
|
|
90
102
|
const IP = comment.ip
|
|
91
|
-
const MAIL = comment.mail
|
|
103
|
+
const MAIL = escapeHtml(comment.mail)
|
|
92
104
|
const COMMENT = comment.comment
|
|
93
105
|
const SITE_URL = config.SITE_URL
|
|
94
|
-
const POST_URL = fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
|
|
106
|
+
const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
|
|
95
107
|
const emailSubject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}上有新评论了`
|
|
96
108
|
let emailContent
|
|
97
109
|
if (config.MAIL_TEMPLATE_ADMIN) {
|
|
@@ -160,12 +172,12 @@ const fn = {
|
|
|
160
172
|
// 即时消息推送内容获取
|
|
161
173
|
getIMPushContent (comment, config) {
|
|
162
174
|
const SITE_NAME = config.SITE_NAME
|
|
163
|
-
const NICK = comment.nick
|
|
164
|
-
const MAIL = comment.mail
|
|
175
|
+
const NICK = escapeHtml(comment.nick)
|
|
176
|
+
const MAIL = escapeHtml(comment.mail)
|
|
165
177
|
const IP = comment.ip
|
|
166
178
|
const COMMENT = $(comment.comment).text()
|
|
167
179
|
const SITE_URL = config.SITE_URL
|
|
168
|
-
const POST_URL = fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
|
|
180
|
+
const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
|
|
169
181
|
const subject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}有新评论了`
|
|
170
182
|
const content = `评论人:${NICK} ([${MAIL}](mailto:${MAIL}))
|
|
171
183
|
|
|
@@ -199,14 +211,14 @@ const fn = {
|
|
|
199
211
|
logger.info('回复自己的评论,不邮件通知')
|
|
200
212
|
return
|
|
201
213
|
}
|
|
202
|
-
const PARENT_NICK = parentComment.nick
|
|
214
|
+
const PARENT_NICK = escapeHtml(parentComment.nick)
|
|
203
215
|
const IMG = getAvatar(currentComment, config)
|
|
204
216
|
const PARENT_IMG = getAvatar(parentComment, config)
|
|
205
217
|
const SITE_NAME = config.SITE_NAME
|
|
206
|
-
const NICK = currentComment.nick
|
|
218
|
+
const NICK = escapeHtml(currentComment.nick)
|
|
207
219
|
const COMMENT = currentComment.comment
|
|
208
220
|
const PARENT_COMMENT = parentComment.comment
|
|
209
|
-
const POST_URL = fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id)
|
|
221
|
+
const POST_URL = escapeHtml(fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id))
|
|
210
222
|
const SITE_URL = config.SITE_URL
|
|
211
223
|
const emailSubject = config.MAIL_SUBJECT || `${PARENT_NICK},您在『${SITE_NAME}』上的评论收到了回复`
|
|
212
224
|
let emailContent
|
package/utils/spam.js
CHANGED
|
@@ -12,6 +12,7 @@ const CryptoJS = getCryptoJS()
|
|
|
12
12
|
const logger = require('./logger')
|
|
13
13
|
|
|
14
14
|
let tencentcloud
|
|
15
|
+
let generateTextPromise
|
|
15
16
|
|
|
16
17
|
function getTencentCloud () {
|
|
17
18
|
if (!tencentcloud) {
|
|
@@ -24,6 +25,18 @@ function getTencentCloud () {
|
|
|
24
25
|
return tencentcloud
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function getGenerateText () {
|
|
29
|
+
if (!generateTextPromise) {
|
|
30
|
+
generateTextPromise = import('@xsai/generate-text')
|
|
31
|
+
.then(({ generateText }) => generateText)
|
|
32
|
+
.catch((error) => {
|
|
33
|
+
generateTextPromise = null
|
|
34
|
+
throw error
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
return generateTextPromise
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
// 提取json结构的函数
|
|
28
41
|
function extractJson (rawText) {
|
|
29
42
|
if (!rawText) return ''
|
|
@@ -121,8 +134,10 @@ async function checkByLLM (comment, config) {
|
|
|
121
134
|
messages = buildMessages(comment, lastError)
|
|
122
135
|
}
|
|
123
136
|
|
|
124
|
-
const
|
|
137
|
+
const generateText = await getGenerateText()
|
|
125
138
|
const chatCompletion = await generateText({
|
|
139
|
+
apiKey: config.LLM_API_KEY,
|
|
140
|
+
baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1',
|
|
126
141
|
model: config.LLM_MODEL || 'deepseek-chat',
|
|
127
142
|
responseFormat: { type: 'json_object' },
|
|
128
143
|
messages
|