twikoo-func 1.7.24 → 2.0.0-beta.2
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/README.md +41 -4
- package/dist/index.d.mts +104 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +215 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +210 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +40 -22
- package/index.js +0 -1178
- package/utils/cap.js +0 -311
- package/utils/constants.js +0 -20
- package/utils/image.js +0 -376
- package/utils/import.js +0 -290
- package/utils/index.js +0 -512
- package/utils/lib.js +0 -79
- package/utils/logger.js +0 -22
- package/utils/notify.js +0 -309
- package/utils/spam.js +0 -241
package/utils/index.js
DELETED
|
@@ -1,512 +0,0 @@
|
|
|
1
|
-
const { URL } = require('url')
|
|
2
|
-
const {
|
|
3
|
-
getAxios,
|
|
4
|
-
getFormData,
|
|
5
|
-
getBowser,
|
|
6
|
-
getIpToRegion,
|
|
7
|
-
getMd5,
|
|
8
|
-
getSha256
|
|
9
|
-
} = require('./lib')
|
|
10
|
-
const axios = getAxios()
|
|
11
|
-
const FormData = getFormData()
|
|
12
|
-
const bowser = getBowser()
|
|
13
|
-
const md5 = getMd5()
|
|
14
|
-
const sha256 = getSha256()
|
|
15
|
-
const { RES_CODE } = require('./constants')
|
|
16
|
-
const logger = require('./logger')
|
|
17
|
-
|
|
18
|
-
let ipRegionSearcher
|
|
19
|
-
|
|
20
|
-
// IP 属地查询
|
|
21
|
-
function getIpRegionSearcher () {
|
|
22
|
-
if (!ipRegionSearcher) {
|
|
23
|
-
const ipToRegion = getIpToRegion()
|
|
24
|
-
ipRegionSearcher = ipToRegion.create() // 初始化 IP 属地
|
|
25
|
-
}
|
|
26
|
-
return ipRegionSearcher
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const fn = {
|
|
30
|
-
// 获取 Twikoo 云函数版本
|
|
31
|
-
getFuncVersion (VERSION) {
|
|
32
|
-
return {
|
|
33
|
-
code: RES_CODE.SUCCESS,
|
|
34
|
-
version: VERSION
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
// 同时查询 /path 和 /path/ 的评论
|
|
38
|
-
getUrlQuery (url) {
|
|
39
|
-
const variantUrl = url[url.length - 1] === '/' ? url.substring(0, url.length - 1) : `${url}/`
|
|
40
|
-
return [url, variantUrl]
|
|
41
|
-
},
|
|
42
|
-
getUrlsQuery (urls) {
|
|
43
|
-
const query = []
|
|
44
|
-
for (const url of urls) {
|
|
45
|
-
if (url) query.push(...fn.getUrlQuery(url))
|
|
46
|
-
}
|
|
47
|
-
return query
|
|
48
|
-
},
|
|
49
|
-
// 筛除隐私字段,拼接回复列表
|
|
50
|
-
parseComment (comments, uid, config) {
|
|
51
|
-
const result = []
|
|
52
|
-
for (const comment of comments) {
|
|
53
|
-
if (!comment.rid) {
|
|
54
|
-
const replies = comments
|
|
55
|
-
.filter((item) => item.rid === comment._id.toString())
|
|
56
|
-
.map((item) => fn.toCommentDto(item, uid, [], comments, config))
|
|
57
|
-
.sort((a, b) => a.created - b.created)
|
|
58
|
-
result.push(fn.toCommentDto(comment, uid, replies, [], config))
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return result
|
|
62
|
-
},
|
|
63
|
-
// 将评论记录转换为前端需要的格式
|
|
64
|
-
toCommentDto (comment, uid, replies = [], comments = [], config) {
|
|
65
|
-
let displayOs = ''
|
|
66
|
-
let displayBrowser = ''
|
|
67
|
-
if (config.SHOW_UA !== 'false') {
|
|
68
|
-
try {
|
|
69
|
-
const ua = bowser.getParser(comment.ua)
|
|
70
|
-
const os = fn.fixOS(ua)
|
|
71
|
-
displayOs = [os.name, os.versionName ? os.versionName : os.version].join(' ')
|
|
72
|
-
displayBrowser = [ua.getBrowserName(), ua.getBrowserVersion()].join(' ')
|
|
73
|
-
} catch (e) {
|
|
74
|
-
logger.warn('bowser 错误:', e)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
const showRegion = !!config.SHOW_REGION && config.SHOW_REGION !== 'false'
|
|
78
|
-
const ups = comment.ups || []
|
|
79
|
-
const downs = comment.downs || []
|
|
80
|
-
return {
|
|
81
|
-
id: comment._id.toString(),
|
|
82
|
-
nick: comment.nick,
|
|
83
|
-
avatar: comment.avatar,
|
|
84
|
-
mailMd5: fn.getMailMd5(comment),
|
|
85
|
-
link: comment.link,
|
|
86
|
-
comment: comment.comment,
|
|
87
|
-
os: displayOs,
|
|
88
|
-
browser: displayBrowser,
|
|
89
|
-
ipRegion: showRegion ? fn.getIpRegion({ ip: comment.ip }) : '',
|
|
90
|
-
master: comment.master,
|
|
91
|
-
like: comment.like ? comment.like.length : 0,
|
|
92
|
-
ups: ups.length,
|
|
93
|
-
downs: downs.length,
|
|
94
|
-
liked: ups.includes(uid),
|
|
95
|
-
disliked: downs.includes(uid),
|
|
96
|
-
replies,
|
|
97
|
-
rid: comment.rid,
|
|
98
|
-
pid: comment.pid,
|
|
99
|
-
ruser: fn.ruser(comment.pid, comments),
|
|
100
|
-
top: comment.top,
|
|
101
|
-
isSpam: comment.isSpam,
|
|
102
|
-
isOwner: Boolean(uid && comment.uid === uid),
|
|
103
|
-
created: comment.created,
|
|
104
|
-
updated: comment.updated
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
fixOS (ua) {
|
|
108
|
-
const os = ua.getOS()
|
|
109
|
-
if (!os.versionName) {
|
|
110
|
-
// fix version name of Win 11 & macOS ^11 & Android ^10
|
|
111
|
-
if (os.name === 'Windows' && os.version === 'NT 11.0') {
|
|
112
|
-
os.versionName = '11'
|
|
113
|
-
} else if (os.name === 'macOS') {
|
|
114
|
-
const majorPlatformVersion = os.version.split('.')[0]
|
|
115
|
-
os.versionName = {
|
|
116
|
-
11: 'Big Sur',
|
|
117
|
-
12: 'Monterey',
|
|
118
|
-
13: 'Ventura',
|
|
119
|
-
14: 'Sonoma',
|
|
120
|
-
15: 'Sequoia',
|
|
121
|
-
16: 'Tahoe'
|
|
122
|
-
}[majorPlatformVersion]
|
|
123
|
-
} else if (os.name === 'Android') {
|
|
124
|
-
const majorPlatformVersion = os.version.split('.')[0]
|
|
125
|
-
os.versionName = {
|
|
126
|
-
10: 'Quince Tart',
|
|
127
|
-
11: 'Red Velvet Cake',
|
|
128
|
-
12: 'Snow Cone',
|
|
129
|
-
13: 'Tiramisu',
|
|
130
|
-
14: 'Upside Down Cake',
|
|
131
|
-
15: 'Vanilla Ice Cream',
|
|
132
|
-
16: 'Baklava'
|
|
133
|
-
}[majorPlatformVersion]
|
|
134
|
-
} else if (ua.test(/harmony/i)) {
|
|
135
|
-
os.name = 'Harmony'
|
|
136
|
-
os.version = fn.getFirstMatch(/harmony[\s/-](\d+(\.\d+)*)/i, ua.getUA())
|
|
137
|
-
os.versionName = ''
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return os
|
|
141
|
-
},
|
|
142
|
-
/**
|
|
143
|
-
* Get first matched item for a string
|
|
144
|
-
* @param {RegExp} regexp
|
|
145
|
-
* @param {String} ua
|
|
146
|
-
* @return {Array|{index: number, input: string}|*|boolean|string}
|
|
147
|
-
*/
|
|
148
|
-
getFirstMatch (regexp, ua) {
|
|
149
|
-
const match = ua.match(regexp)
|
|
150
|
-
return (match && match.length > 0 && match[1]) || ''
|
|
151
|
-
},
|
|
152
|
-
// 获取回复人昵称 / Get replied user nick name
|
|
153
|
-
ruser (pid, comments = []) {
|
|
154
|
-
const comment = comments.find((item) => item._id === pid)
|
|
155
|
-
return comment ? comment.nick : null
|
|
156
|
-
},
|
|
157
|
-
/**
|
|
158
|
-
* 获取 IP 属地
|
|
159
|
-
* @param detail true 返回省市运营商,false 只返回省
|
|
160
|
-
* @returns {String}
|
|
161
|
-
*/
|
|
162
|
-
getIpRegion ({ ip, detail = false }) {
|
|
163
|
-
if (!ip) return ''
|
|
164
|
-
try {
|
|
165
|
-
// 将 IPv6 格式的 IPv4 地址转换为 IPv4 格式
|
|
166
|
-
ip = ip.replace(/^::ffff:/, '')
|
|
167
|
-
// Zeabur 返回的地址带端口号,去掉端口号。TODO: 不知道该怎么去掉 IPv6 地址后面的端口号
|
|
168
|
-
ip = ip.replace(/:[0-9]*$/, '')
|
|
169
|
-
const { region } = getIpRegionSearcher().binarySearchSync(ip)
|
|
170
|
-
const [country,, province, city, isp] = region.split('|')
|
|
171
|
-
// 有省显示省,没有省显示国家
|
|
172
|
-
const area = province.trim() && province !== '0' ? province : country
|
|
173
|
-
if (detail) {
|
|
174
|
-
return area === city ? [city, isp].join(' ') : [area, city, isp].join(' ')
|
|
175
|
-
} else {
|
|
176
|
-
return area.replace(/(省|市)$/, '')
|
|
177
|
-
}
|
|
178
|
-
} catch (e) {
|
|
179
|
-
logger.warn('IP 属地查询失败:', e.message, ip)
|
|
180
|
-
return ''
|
|
181
|
-
}
|
|
182
|
-
},
|
|
183
|
-
parseCommentForAdmin (comments) {
|
|
184
|
-
for (const comment of comments) {
|
|
185
|
-
comment.ipRegion = fn.getIpRegion({ ip: comment.ip, detail: true })
|
|
186
|
-
}
|
|
187
|
-
return comments
|
|
188
|
-
},
|
|
189
|
-
getRelativeUrl (url) {
|
|
190
|
-
try {
|
|
191
|
-
return (new URL(url)).pathname
|
|
192
|
-
} catch (e) {
|
|
193
|
-
// 如果 url 已经是一个相对地址了,会报 ERR_INVALID_URL,返回原始 url 就行
|
|
194
|
-
return url
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
normalizeMail (mail) {
|
|
198
|
-
return String(mail).trim().toLowerCase()
|
|
199
|
-
},
|
|
200
|
-
equalsMail (mail1, mail2) {
|
|
201
|
-
if (!mail1 || !mail2) return false
|
|
202
|
-
return fn.normalizeMail(mail1) === fn.normalizeMail(mail2)
|
|
203
|
-
},
|
|
204
|
-
getMailMd5 (comment) {
|
|
205
|
-
if (comment.mailMd5) {
|
|
206
|
-
return comment.mailMd5
|
|
207
|
-
}
|
|
208
|
-
if (comment.mail) {
|
|
209
|
-
return md5(fn.normalizeMail(comment.mail))
|
|
210
|
-
}
|
|
211
|
-
return md5(comment.nick)
|
|
212
|
-
},
|
|
213
|
-
getMailSha256 (comment) {
|
|
214
|
-
if (comment.mail) {
|
|
215
|
-
return sha256(fn.normalizeMail(comment.mail))
|
|
216
|
-
}
|
|
217
|
-
return sha256(comment.nick)
|
|
218
|
-
},
|
|
219
|
-
getAvatar (comment, config) {
|
|
220
|
-
if (comment.avatar) {
|
|
221
|
-
return comment.avatar
|
|
222
|
-
} else {
|
|
223
|
-
const gravatarCdn = config.GRAVATAR_CDN || 'weavatar.com'
|
|
224
|
-
let defaultGravatar = `initials&name=${comment.nick}`
|
|
225
|
-
if (config.DEFAULT_GRAVATAR) {
|
|
226
|
-
defaultGravatar = config.DEFAULT_GRAVATAR
|
|
227
|
-
}
|
|
228
|
-
const mailHash = gravatarCdn === 'cravatar.cn' ? fn.getMailMd5(comment) : fn.getMailSha256(comment) // Cravatar 不支持 sha256
|
|
229
|
-
return `https://${gravatarCdn}/avatar/${mailHash}?d=${defaultGravatar}`
|
|
230
|
-
}
|
|
231
|
-
},
|
|
232
|
-
isUrl (s) {
|
|
233
|
-
return /^http(s)?:\/\//.test(s)
|
|
234
|
-
},
|
|
235
|
-
isValidEmail (mail) {
|
|
236
|
-
if (!mail || typeof mail !== 'string') return false
|
|
237
|
-
const trimmed = mail.trim()
|
|
238
|
-
if (!trimmed) return false
|
|
239
|
-
// Reject emails with characters that could trigger nodemailer addressparser group parsing (CVE-2025-14874)
|
|
240
|
-
if (trimmed.indexOf(':') !== -1) return false
|
|
241
|
-
if (trimmed.indexOf(' ') !== -1) return false
|
|
242
|
-
if (trimmed.indexOf(';') !== -1) return false
|
|
243
|
-
// Basic email format validation
|
|
244
|
-
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
|
|
245
|
-
},
|
|
246
|
-
isQQ (mail) {
|
|
247
|
-
return /^[1-9][0-9]{4,10}$/.test(mail) ||
|
|
248
|
-
/^[1-9][0-9]{4,10}@qq.com$/i.test(mail)
|
|
249
|
-
},
|
|
250
|
-
addQQMailSuffix (mail) {
|
|
251
|
-
if (/^[1-9][0-9]{4,10}$/.test(mail)) return `${mail}@qq.com`
|
|
252
|
-
else return mail
|
|
253
|
-
},
|
|
254
|
-
async getQQAvatar (qq) {
|
|
255
|
-
try {
|
|
256
|
-
const qqNum = qq.replace(/@qq.com/ig, '')
|
|
257
|
-
// TODO: 这个接口已经失效了,暂时找不到新的接口
|
|
258
|
-
const result = await axios.get(`https://aq.qq.com/cn2/get_img/get_face?img_type=3&uin=${qqNum}`)
|
|
259
|
-
return result.data?.url || null
|
|
260
|
-
} catch (e) {
|
|
261
|
-
logger.warn('获取 QQ 头像失败:', e)
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
|
-
async getQQNick (qq, qqApiKey) {
|
|
265
|
-
try {
|
|
266
|
-
const qqNum = qq.replace(/@qq.com/ig, '')
|
|
267
|
-
const headers = {}
|
|
268
|
-
if (qqApiKey) {
|
|
269
|
-
headers.Authorization = `Bearer ${qqApiKey}`
|
|
270
|
-
}
|
|
271
|
-
const result = await axios.get(`https://v1.tqq.me/v1/qqname?qq=${qqNum}`, { headers })
|
|
272
|
-
if (result.data?.code === 200 && result.data?.data?.nick) {
|
|
273
|
-
return result.data.data.nick
|
|
274
|
-
}
|
|
275
|
-
return null
|
|
276
|
-
} catch (e) {
|
|
277
|
-
logger.warn('获取 QQ 昵称失败:', e)
|
|
278
|
-
return null
|
|
279
|
-
}
|
|
280
|
-
},
|
|
281
|
-
// 判断是否存在管理员密码
|
|
282
|
-
async getPasswordStatus (config, version) {
|
|
283
|
-
return {
|
|
284
|
-
code: RES_CODE.SUCCESS,
|
|
285
|
-
status: !!config.ADMIN_PASS,
|
|
286
|
-
credentials: !!config.CREDENTIALS,
|
|
287
|
-
version
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
// 预垃圾评论检测
|
|
291
|
-
preCheckSpam ({ comment, nick, link, mail }, config) {
|
|
292
|
-
// 长度限制
|
|
293
|
-
let limitLength = parseInt(config.LIMIT_LENGTH)
|
|
294
|
-
if (Number.isNaN(limitLength)) limitLength = 500
|
|
295
|
-
if (limitLength && comment.length > limitLength) {
|
|
296
|
-
throw new Error('评论内容过长')
|
|
297
|
-
}
|
|
298
|
-
if (config.BLOCKED_WORDS) {
|
|
299
|
-
const commentLowerCase = comment.toLowerCase()
|
|
300
|
-
const nickLowerCase = nick.toLowerCase()
|
|
301
|
-
for (const blockedWord of config.BLOCKED_WORDS.split(',')) {
|
|
302
|
-
const blockedWordLowerCase = blockedWord.trim().toLowerCase()
|
|
303
|
-
if (commentLowerCase.indexOf(blockedWordLowerCase) !== -1 || nickLowerCase.indexOf(blockedWordLowerCase) !== -1) {
|
|
304
|
-
throw new Error('包含屏蔽词')
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
if (config.AKISMET_KEY === 'MANUAL_REVIEW') {
|
|
309
|
-
// 人工审核
|
|
310
|
-
logger.info('已使用人工审核模式,评论审核后才会发表~')
|
|
311
|
-
return true
|
|
312
|
-
} else if (config.FORBIDDEN_WORDS) {
|
|
313
|
-
// 违禁词检测
|
|
314
|
-
const commentLowerCase = comment.toLowerCase()
|
|
315
|
-
const nickLowerCase = nick.toLowerCase()
|
|
316
|
-
const linkLowerCase = (link || '').toLowerCase()
|
|
317
|
-
const mailLowerCase = (mail || '').toLowerCase()
|
|
318
|
-
for (const forbiddenWord of config.FORBIDDEN_WORDS.replace(/,+$/, '').split(',')) {
|
|
319
|
-
const forbiddenWordLowerCase = forbiddenWord.trim().toLowerCase()
|
|
320
|
-
if (commentLowerCase.indexOf(forbiddenWordLowerCase) !== -1 || nickLowerCase.indexOf(forbiddenWordLowerCase) !== -1 || linkLowerCase.indexOf(forbiddenWordLowerCase) !== -1 || mailLowerCase.indexOf(forbiddenWordLowerCase) !== -1) {
|
|
321
|
-
logger.warn('包含违禁词,直接标记为垃圾评论~')
|
|
322
|
-
return true
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return false
|
|
327
|
-
},
|
|
328
|
-
async checkTurnstileCaptcha ({ ip, turnstileToken, turnstileTokenSecretKey }) {
|
|
329
|
-
try {
|
|
330
|
-
const formData = new FormData()
|
|
331
|
-
formData.append('secret', turnstileTokenSecretKey)
|
|
332
|
-
formData.append('response', turnstileToken)
|
|
333
|
-
formData.append('remoteip', ip)
|
|
334
|
-
const { data } = await axios.post('https://challenges.cloudflare.com/turnstile/v0/siteverify', formData, {
|
|
335
|
-
headers: formData.getHeaders()
|
|
336
|
-
})
|
|
337
|
-
logger.log('验证码检测结果', data)
|
|
338
|
-
if (!data.success) throw new Error('验证码错误')
|
|
339
|
-
} catch (e) {
|
|
340
|
-
throw new Error('验证码检测失败: ' + e.message)
|
|
341
|
-
}
|
|
342
|
-
},
|
|
343
|
-
async checkGeeTestCaptcha ({ geeTestCaptchaId, geeTestCaptchaKey, geeTestLotNumber, geeTestCaptchaOutput, geeTestPassToken, geeTestGenTime }) {
|
|
344
|
-
try {
|
|
345
|
-
logger.log('极验验证参数:', { geeTestCaptchaId, geeTestCaptchaKey: geeTestCaptchaKey ? '***' : undefined, geeTestLotNumber })
|
|
346
|
-
const crypto = require('crypto')
|
|
347
|
-
const signToken = crypto
|
|
348
|
-
.createHmac('sha256', geeTestCaptchaKey)
|
|
349
|
-
.update(geeTestLotNumber)
|
|
350
|
-
.digest('hex')
|
|
351
|
-
const params = new URLSearchParams()
|
|
352
|
-
params.append('lot_number', geeTestLotNumber)
|
|
353
|
-
params.append('captcha_output', geeTestCaptchaOutput)
|
|
354
|
-
params.append('pass_token', geeTestPassToken)
|
|
355
|
-
params.append('gen_time', geeTestGenTime)
|
|
356
|
-
params.append('sign_token', signToken)
|
|
357
|
-
logger.log('极验请求参数:', params.toString())
|
|
358
|
-
const url = `https://gcaptcha4.geetest.com/validate?captcha_id=${geeTestCaptchaId}`
|
|
359
|
-
const { data } = await axios.post(url, params.toString(), {
|
|
360
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
361
|
-
})
|
|
362
|
-
logger.log('极验验证码检测结果', JSON.stringify(data))
|
|
363
|
-
if (data.result !== 'success') {
|
|
364
|
-
logger.error('极验验证失败详情:', data)
|
|
365
|
-
throw new Error(data.reason || data.msg || '验证码错误')
|
|
366
|
-
}
|
|
367
|
-
} catch (e) {
|
|
368
|
-
throw new Error('极验验证码检测失败: ' + e.message)
|
|
369
|
-
}
|
|
370
|
-
},
|
|
371
|
-
async checkCapCaptcha ({ capToken, capSecretKey, capApiEndpoint, cap }) {
|
|
372
|
-
try {
|
|
373
|
-
// 内嵌 Cap:直接 validateToken,无需外部 Standalone
|
|
374
|
-
if (cap) {
|
|
375
|
-
const { validateToken } = require('./cap')
|
|
376
|
-
const ok = await validateToken(cap, capToken)
|
|
377
|
-
if (!ok) throw new Error('验证码错误')
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
// 外部 Cap Standalone:HTTP siteverify
|
|
381
|
-
const endpoint = capApiEndpoint.replace(/\/$/, '')
|
|
382
|
-
const url = `${endpoint}/siteverify`
|
|
383
|
-
logger.log('Cap验证码验证URL:', url)
|
|
384
|
-
logger.log('Cap验证码验证参数:', { secret: capSecretKey ? '***' : undefined, response: capToken.substring(0, 20) + '...' })
|
|
385
|
-
const { data } = await axios.post(url, {
|
|
386
|
-
secret: capSecretKey,
|
|
387
|
-
response: capToken
|
|
388
|
-
}, {
|
|
389
|
-
headers: { 'Content-Type': 'application/json' }
|
|
390
|
-
})
|
|
391
|
-
logger.log('Cap验证码检测结果', data)
|
|
392
|
-
if (!data.success) throw new Error(data.error || '验证码错误')
|
|
393
|
-
} catch (e) {
|
|
394
|
-
throw new Error('Cap验证码检测失败: ' + e.message)
|
|
395
|
-
}
|
|
396
|
-
},
|
|
397
|
-
async getConfig ({ config, VERSION, isAdmin }) {
|
|
398
|
-
// 构建对外配置,避免在启用某一验证码供应商时泄露另一个供应商的 key
|
|
399
|
-
const baseConfig = {
|
|
400
|
-
VERSION,
|
|
401
|
-
IS_ADMIN: isAdmin,
|
|
402
|
-
SITE_NAME: config.SITE_NAME,
|
|
403
|
-
SITE_URL: config.SITE_URL,
|
|
404
|
-
MASTER_TAG: config.MASTER_TAG,
|
|
405
|
-
COMMENT_BG_IMG: config.COMMENT_BG_IMG,
|
|
406
|
-
GRAVATAR_CDN: config.GRAVATAR_CDN,
|
|
407
|
-
DEFAULT_GRAVATAR: config.DEFAULT_GRAVATAR,
|
|
408
|
-
SHOW_IMAGE: config.SHOW_IMAGE || 'true',
|
|
409
|
-
IMAGE_CDN: config.IMAGE_CDN,
|
|
410
|
-
LIGHTBOX: config.LIGHTBOX || 'false',
|
|
411
|
-
SHOW_EMOTION: config.SHOW_EMOTION || 'true',
|
|
412
|
-
EMOTION_CDN: config.EMOTION_CDN,
|
|
413
|
-
COMMENT_PLACEHOLDER: config.COMMENT_PLACEHOLDER,
|
|
414
|
-
SHOW_ORDER: config.SHOW_ORDER || 'true',
|
|
415
|
-
SHOW_DISLIKE: config.SHOW_DISLIKE || 'true',
|
|
416
|
-
DISPLAYED_FIELDS: config.DISPLAYED_FIELDS,
|
|
417
|
-
REQUIRED_FIELDS: config.REQUIRED_FIELDS,
|
|
418
|
-
HIDE_ADMIN_CRYPT: config.HIDE_ADMIN_CRYPT,
|
|
419
|
-
HIGHLIGHT: config.HIGHLIGHT || 'true',
|
|
420
|
-
HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
|
|
421
|
-
HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
|
|
422
|
-
LIMIT_LENGTH: config.LIMIT_LENGTH,
|
|
423
|
-
CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// 仅在明确指定使用 Turnstile 时下发 Turnstile 的 site key
|
|
427
|
-
if (config.CAPTCHA_PROVIDER === 'Turnstile') {
|
|
428
|
-
baseConfig.TURNSTILE_SITE_KEY = config.TURNSTILE_SITE_KEY
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// 仅在明确指定使用 Geetest 时下发 Geetest 的 id
|
|
432
|
-
if (config.CAPTCHA_PROVIDER === 'Geetest') {
|
|
433
|
-
baseConfig.GEETEST_CAPTCHA_ID = config.GEETEST_CAPTCHA_ID
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Cap:有外部 endpoint 则下发;否则标记 builtin,前端走 twikoo 事件代理
|
|
437
|
-
if (config.CAPTCHA_PROVIDER === 'Cap') {
|
|
438
|
-
if (config.CAP_API_ENDPOINT) {
|
|
439
|
-
baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
|
|
440
|
-
} else {
|
|
441
|
-
baseConfig.CAP_BUILTIN = true
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
return {
|
|
446
|
-
code: RES_CODE.SUCCESS,
|
|
447
|
-
config: baseConfig
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
async getConfigForAdmin ({ config, isAdmin }) {
|
|
451
|
-
if (isAdmin) {
|
|
452
|
-
delete config.CREDENTIALS
|
|
453
|
-
return {
|
|
454
|
-
code: RES_CODE.SUCCESS,
|
|
455
|
-
config
|
|
456
|
-
}
|
|
457
|
-
} else {
|
|
458
|
-
return {
|
|
459
|
-
code: RES_CODE.NEED_LOGIN,
|
|
460
|
-
message: '请先登录'
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
},
|
|
464
|
-
// 请求参数校验
|
|
465
|
-
validate (event = {}, requiredParams = []) {
|
|
466
|
-
for (const requiredParam of requiredParams) {
|
|
467
|
-
if (!event[requiredParam]) {
|
|
468
|
-
throw new Error(`参数"${requiredParam}"不合法`)
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
},
|
|
472
|
-
// 客户端字段类型校验,防止 NoSQL 查询条件注入。
|
|
473
|
-
// 以下字段会直接参与数据库查询或作为评论归属标识,
|
|
474
|
-
// 如果传入对象,会被数据库解释为查询操作符(如 $ne、$gt),
|
|
475
|
-
// 导致越权删除评论、绕过评论可见性限制等问题。
|
|
476
|
-
// 允许字段缺省或为 null(兼容首次匿名请求),有值时必须是字符串。
|
|
477
|
-
validateClientFields (event = {}) {
|
|
478
|
-
const stringFields = ['accessToken', 'id', 'url', 'pid', 'rid']
|
|
479
|
-
for (const field of stringFields) {
|
|
480
|
-
const value = event[field]
|
|
481
|
-
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
482
|
-
throw new Error(`参数"${field}"必须是字符串`)
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
if (event.urls !== undefined && event.urls !== null) {
|
|
486
|
-
if (!Array.isArray(event.urls) || event.urls.some((url) => typeof url !== 'string')) {
|
|
487
|
-
throw new Error('参数"urls"必须是字符串数组')
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
},
|
|
491
|
-
// 校验评论归属:确认评论存在且属于当前用户
|
|
492
|
-
async checkCommentOwnership (id, uid, getComment) {
|
|
493
|
-
fn.validate({ id }, ['id'])
|
|
494
|
-
// 兜底校验:id 必须是字符串,防止查询操作符对象注入 _id 条件,
|
|
495
|
-
// 绕过归属校验后批量删除评论
|
|
496
|
-
if (typeof id !== 'string') {
|
|
497
|
-
throw new Error('参数"id"必须是字符串')
|
|
498
|
-
}
|
|
499
|
-
const comment = await getComment(id)
|
|
500
|
-
if (!comment) {
|
|
501
|
-
throw new Error('评论不存在')
|
|
502
|
-
}
|
|
503
|
-
// 无 token 的请求不具备任何归属权,必须直接拒绝,
|
|
504
|
-
// 防止 comment.uid 与 uid 同时为 undefined 时误判为本人
|
|
505
|
-
if (!uid || comment.uid !== uid) {
|
|
506
|
-
throw new Error('只能删除自己的评论')
|
|
507
|
-
}
|
|
508
|
-
return comment
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
module.exports = fn
|
package/utils/lib.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
const crypto = require('crypto')
|
|
2
|
-
|
|
3
|
-
let customLibs = {}
|
|
4
|
-
|
|
5
|
-
module.exports = {
|
|
6
|
-
setCustomLibs (libs) {
|
|
7
|
-
customLibs = libs
|
|
8
|
-
},
|
|
9
|
-
getHtmlToText () {
|
|
10
|
-
const { compile } = require('html-to-text') // HTML 转纯文本
|
|
11
|
-
return compile({
|
|
12
|
-
wordwrap: false,
|
|
13
|
-
selectors: [
|
|
14
|
-
{ selector: 'a', options: { ignoreHref: true } },
|
|
15
|
-
{ selector: 'img', format: 'skip' }
|
|
16
|
-
]
|
|
17
|
-
})
|
|
18
|
-
},
|
|
19
|
-
getAkismetClient () {
|
|
20
|
-
const { AkismetClient } = require('akismet-api') // 反垃圾 API
|
|
21
|
-
return AkismetClient
|
|
22
|
-
},
|
|
23
|
-
getFormData () {
|
|
24
|
-
const FormData = require('form-data') // 图片上传
|
|
25
|
-
return FormData
|
|
26
|
-
},
|
|
27
|
-
getAxios () {
|
|
28
|
-
const axios = require('axios') // 发送 REST 请求
|
|
29
|
-
return axios
|
|
30
|
-
},
|
|
31
|
-
getBowser () {
|
|
32
|
-
const bowser = require('bowser') // UserAgent 格式化
|
|
33
|
-
return bowser
|
|
34
|
-
},
|
|
35
|
-
getDomPurify () {
|
|
36
|
-
if (customLibs.DOMPurify) return customLibs.DOMPurify
|
|
37
|
-
// 初始化反 XSS
|
|
38
|
-
const { JSDOM } = require('jsdom') // document.window 服务器版
|
|
39
|
-
const createDOMPurify = require('dompurify') // 反 XSS
|
|
40
|
-
const window = new JSDOM('').window
|
|
41
|
-
const DOMPurify = createDOMPurify(window)
|
|
42
|
-
return DOMPurify
|
|
43
|
-
},
|
|
44
|
-
getIpToRegion () {
|
|
45
|
-
const ipToRegion = require('@imaegoo/node-ip2region') // IP 属地查询
|
|
46
|
-
return ipToRegion
|
|
47
|
-
},
|
|
48
|
-
getMarked () {
|
|
49
|
-
const marked = require('marked') // Markdown 解析
|
|
50
|
-
return marked
|
|
51
|
-
},
|
|
52
|
-
getMd5 () {
|
|
53
|
-
return (message) => {
|
|
54
|
-
return crypto.createHash('md5').update(String(message)).digest('hex')
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
getSha256 () {
|
|
58
|
-
return (message) => {
|
|
59
|
-
return crypto.createHash('sha256').update(message == null ? '' : String(message)).digest('hex')
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
getNodemailer () {
|
|
63
|
-
if (customLibs.nodemailer) return customLibs.nodemailer
|
|
64
|
-
const nodemailer = require('nodemailer') // 发送邮件
|
|
65
|
-
return nodemailer
|
|
66
|
-
},
|
|
67
|
-
getPushoo () {
|
|
68
|
-
const pushoo = require('pushoo').default // 即时消息通知
|
|
69
|
-
return pushoo
|
|
70
|
-
},
|
|
71
|
-
getTencentcloudTms () {
|
|
72
|
-
const tencentcloudTms = require('tencentcloud-sdk-nodejs-tms') // 腾讯云文本内容安全 SDK
|
|
73
|
-
return tencentcloudTms
|
|
74
|
-
},
|
|
75
|
-
getXml2js () {
|
|
76
|
-
const xml2js = require('xml2js') // XML 解析
|
|
77
|
-
return xml2js
|
|
78
|
-
}
|
|
79
|
-
}
|
package/utils/logger.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
let envLogLevel = process.env.TWIKOO_LOG_LEVEL || 'info'
|
|
2
|
-
envLogLevel = envLogLevel.toLowerCase()
|
|
3
|
-
const logLevel = { verbose: 1, info: 2, warn: 3, error: 4 }[envLogLevel] || 2
|
|
4
|
-
|
|
5
|
-
const logger = {
|
|
6
|
-
log: (...messages) => {
|
|
7
|
-
if (logLevel <= 1) console.log(logPrefix(), ...messages)
|
|
8
|
-
},
|
|
9
|
-
info: (...messages) => {
|
|
10
|
-
if (logLevel <= 2) console.info(logPrefix(), ...messages)
|
|
11
|
-
},
|
|
12
|
-
warn: (...messages) => {
|
|
13
|
-
if (logLevel <= 3) console.warn(logPrefix(), ...messages)
|
|
14
|
-
},
|
|
15
|
-
error: (...messages) => {
|
|
16
|
-
if (logLevel <= 4) console.error(logPrefix(), ...messages)
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const logPrefix = () => `${new Date().toLocaleString()} Twikoo:`
|
|
21
|
-
|
|
22
|
-
module.exports = logger
|