tkserver 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/index.js DELETED
@@ -1,1227 +0,0 @@
1
- /*!
2
- * Twikoo self-hosted function
3
- * (c) 2020-present iMaeGoo
4
- * Released under the MIT License.
5
- */
6
-
7
- const { version: VERSION } = require('./package.json')
8
- const fs = require('fs')
9
- const path = require('path')
10
- const Loki = require('lokijs')
11
- const getUserIP = require('get-user-ip')
12
- const Lfsa = require('lokijs/src/loki-fs-structured-adapter')
13
- const { v4: uuidv4 } = require('uuid') // 用户 id 生成
14
- const {
15
- getHtmlToText,
16
- getDomPurify,
17
- getMd5,
18
- getSha256,
19
- getXml2js
20
- } = require('twikoo-func/utils/lib')
21
- const {
22
- getFuncVersion,
23
- getUrlQuery,
24
- getUrlsQuery,
25
- parseComment,
26
- parseCommentForAdmin,
27
- normalizeMail,
28
- equalsMail,
29
- getMailMd5,
30
- getAvatar,
31
- isQQ,
32
- addQQMailSuffix,
33
- getQQAvatar,
34
- getQQNick,
35
- getPasswordStatus,
36
- preCheckSpam,
37
- checkTurnstileCaptcha,
38
- checkGeeTestCaptcha,
39
- checkCapCaptcha,
40
- getConfig,
41
- getConfigForAdmin,
42
- validate,
43
- validateClientFields,
44
- checkCommentOwnership,
45
- isValidEmail
46
- } = require('twikoo-func/utils')
47
- const {
48
- createCap,
49
- lokiStorage,
50
- createChallenge,
51
- redeemChallenge,
52
- isBuiltinCap
53
- } = require('twikoo-func/utils/cap')
54
- const {
55
- jsonParse,
56
- commentImportValine,
57
- commentImportDisqus,
58
- commentImportArtalk,
59
- commentImportArtalk2,
60
- commentImportTwikoo
61
- } = require('twikoo-func/utils/import')
62
- const { postCheckSpam } = require('twikoo-func/utils/spam')
63
- const { sendNotice, emailTest } = require('twikoo-func/utils/notify')
64
- const { uploadImage } = require('twikoo-func/utils/image')
65
- const logger = require('twikoo-func/utils/logger')
66
-
67
- const htmlToText = getHtmlToText()
68
- const DOMPurify = getDomPurify()
69
- const md5 = getMd5()
70
- const sha256 = getSha256()
71
- const xml2js = getXml2js()
72
-
73
- // 常量 / constants
74
- const { RES_CODE, MAX_REQUEST_TIMES } = require('twikoo-func/utils/constants')
75
- const TWIKOO_REQ_TIMES_CLEAR_TIME = parseInt(process.env.TWIKOO_REQ_TIMES_CLEAR_TIME) || 10 * 60 * 1000
76
-
77
- // 全局变量 / variables
78
- let db = null
79
- let config
80
- let requestTimes = {}
81
- let requestTimesTimer = null
82
-
83
- connectToDatabase()
84
-
85
- module.exports = async (request, response) => {
86
- let accessToken
87
- let hasClientToken = false
88
- const event = request.body || {}
89
- logger.log('请求 IP:', getIp(request))
90
- logger.log('请求函数:', event.event)
91
- logger.log('请求参数:', event)
92
- let res = {}
93
- try {
94
- protect(request)
95
- // 统一校验客户端字段类型,防止查询操作符对象注入数据库查询条件
96
- validateClientFields(event)
97
- // 判断客户端是否自带 accessToken,须在 anonymousSignIn 回填身份之前
98
- hasClientToken = !!(request.body && request.body.accessToken)
99
- accessToken = anonymousSignIn(request)
100
- await readConfig()
101
- allowCors(request, response)
102
- if (request.method === 'OPTIONS') {
103
- response.status(204).end()
104
- return
105
- }
106
- switch (event.event) {
107
- case 'GET_FUNC_VERSION':
108
- res = getFuncVersion({ VERSION })
109
- break
110
- case 'COMMENT_GET':
111
- res = await commentGet(event)
112
- break
113
- case 'COMMENT_GET_FOR_ADMIN':
114
- res = await commentGetForAdmin(event)
115
- break
116
- case 'COMMENT_SET_FOR_ADMIN':
117
- res = await commentSetForAdmin(event)
118
- break
119
- case 'COMMENT_DELETE_FOR_ADMIN':
120
- res = await commentDeleteForAdmin(event)
121
- break
122
- case 'COMMENT_DELETE_FOR_USER':
123
- res = await commentDeleteForUser(event)
124
- break
125
- case 'COMMENT_IMPORT_FOR_ADMIN':
126
- res = await commentImportForAdmin(event)
127
- break
128
- case 'COMMENT_LIKE':
129
- res = await commentLike(event)
130
- break
131
- case 'COMMENT_SUBMIT':
132
- res = await commentSubmit(event, request)
133
- break
134
- case 'COUNTER_GET':
135
- res = await counterGet(event)
136
- break
137
- case 'GET_PASSWORD_STATUS':
138
- res = await getPasswordStatus(config, VERSION)
139
- break
140
- case 'SET_PASSWORD':
141
- res = await setPassword(event)
142
- break
143
- case 'GET_CONFIG':
144
- res = await getConfig({ config, VERSION, isAdmin: isAdmin(event.accessToken) })
145
- break
146
- case 'GET_CONFIG_FOR_ADMIN':
147
- res = await getConfigForAdmin({ config, isAdmin: isAdmin(event.accessToken) })
148
- break
149
- case 'SET_CONFIG':
150
- res = await setConfig(event)
151
- break
152
- case 'LOGIN':
153
- res = await login(event.password)
154
- break
155
- case 'GET_COMMENTS_COUNT': // >= 0.2.7
156
- res = await getCommentsCount(event)
157
- break
158
- case 'GET_RECENT_COMMENTS': // >= 0.2.7
159
- res = await getRecentComments(event)
160
- break
161
- case 'EMAIL_TEST': // >= 1.4.6
162
- res = await emailTest(event, config, isAdmin(event.accessToken))
163
- break
164
- case 'UPLOAD_IMAGE': // >= 1.5.0
165
- res = await uploadImage(event, config)
166
- break
167
- case 'GET_QQ_NICK': // >= 1.7.0
168
- res = await qqNickGet(event)
169
- break
170
- case 'COMMENT_EXPORT_FOR_ADMIN': // >= 1.6.13
171
- res = await commentExportForAdmin(event)
172
- break
173
- case 'CAP_CHALLENGE':
174
- res = await capChallenge()
175
- break
176
- case 'CAP_REDEEM':
177
- res = await capRedeem(event)
178
- break
179
- default:
180
- if (event.event) {
181
- res.code = RES_CODE.EVENT_NOT_EXIST
182
- res.message = '请更新 Twikoo 云函数至最新版本'
183
- } else {
184
- res.code = RES_CODE.NO_PARAM
185
- res.message = 'Twikoo 云函数运行正常,请参考 https://twikoo.js.org/frontend.html 完成前端的配置'
186
- res.version = VERSION
187
- }
188
- }
189
- } catch (e) {
190
- logger.error('Twikoo 遇到错误,请参考以下错误信息。如有疑问,请反馈至 https://github.com/twikoojs/twikoo/issues')
191
- logger.error('请求参数:', event)
192
- logger.error('错误信息:', e)
193
- res.code = RES_CODE.FAIL
194
- res.message = e.message
195
- }
196
- // 客户端未携带 accessToken 时,将本次绑定的身份令牌随响应返回,
197
- // 客户端会持久化并在后续请求中携带,从而获得稳定的匿名身份
198
- if (!res.code && !hasClientToken) {
199
- res.accessToken = accessToken
200
- }
201
- logger.log('请求返回:', res)
202
- response.status(200).json(res)
203
- }
204
-
205
- function allowCors (request, response) {
206
- if (request.headers.origin) {
207
- response.setHeader('Access-Control-Allow-Credentials', true)
208
- response.setHeader('Access-Control-Allow-Origin', getAllowedOrigin(request))
209
- response.setHeader('Access-Control-Allow-Methods', 'POST')
210
- response.setHeader(
211
- 'Access-Control-Allow-Headers',
212
- 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
213
- )
214
- response.setHeader('Access-Control-Max-Age', '600')
215
- }
216
- }
217
-
218
- function getAllowedOrigin (request) {
219
- const localhostRegex = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d{1,5})?$/
220
- if (localhostRegex.test(request.headers.origin)) { // 判断是否为本地主机,如是则允许跨域
221
- return request.headers.origin // Allow
222
- } else if (config.CORS_ALLOW_ORIGIN) { // 如设置了安全域名则检查
223
- // 适配多条 CORS 规则
224
- // 以逗号分隔 CORS
225
- const corsList = config.CORS_ALLOW_ORIGIN.split(',')
226
- // 遍历 CORS 列表
227
- for (let i = 0; i < corsList.length; i++) {
228
- const cors = corsList[i].replace(/\/$/, '') // 获取当前 CORS 并去除末尾的斜杠
229
- if (cors === request.headers.origin) {
230
- return request.headers.origin // Allow
231
- }
232
- }
233
- return '' // 不在安全域名列表中则禁止跨域
234
- } else {
235
- return request.headers.origin // 未设置安全域名直接 Allow
236
- }
237
- }
238
-
239
- function anonymousSignIn (request) {
240
- if (request.body) {
241
- if (request.body.accessToken) {
242
- return request.body.accessToken
243
- } else {
244
- // 为匿名访客签发固定身份令牌,写回请求体供本次请求的
245
- // 评论存储(uid)与归属校验使用,避免出现 undefined 身份
246
- request.body.accessToken = uuidv4().replace(/-/g, '')
247
- return request.body.accessToken
248
- }
249
- }
250
- }
251
-
252
- async function connectToDatabase () {
253
- if (db) return db
254
- const dataDir = path.resolve(process.cwd(), process.env.TWIKOO_DATA || './data')
255
- if (!fs.existsSync(dataDir)) {
256
- fs.mkdirSync(dataDir)
257
- }
258
- logger.info(`Twikoo database stored at ${dataDir}`)
259
- await new Promise((resolve) => {
260
- logger.info('Connecting to database...')
261
- db = new Loki(path.resolve(dataDir, './db.json'), {
262
- adapter: new Lfsa(),
263
- autoload: true,
264
- autoloadCallback: resolve,
265
- autosave: true,
266
- autosaveInterval: 4000
267
- })
268
- })
269
- await createCollections()
270
- logger.info('Connected to database')
271
- return db
272
- }
273
-
274
- // 写入管理密码
275
- async function setPassword (event) {
276
- const isAdminUser = isAdmin(event.accessToken)
277
- // 如果数据库里没有密码,则写入密码
278
- // 如果数据库里有密码,则只有管理员可以写入密码
279
- if (config.ADMIN_PASS && !isAdminUser) {
280
- return { code: RES_CODE.PASS_EXIST, message: '请先登录再修改密码' }
281
- }
282
- const ADMIN_PASS = md5(event.password)
283
- await writeConfig({ ADMIN_PASS })
284
- return {
285
- code: RES_CODE.SUCCESS
286
- }
287
- }
288
-
289
- // 管理员登录
290
- async function login (password) {
291
- if (!config) {
292
- return { code: RES_CODE.CONFIG_NOT_EXIST, message: '数据库无配置' }
293
- }
294
- if (!config.ADMIN_PASS) {
295
- return { code: RES_CODE.PASS_NOT_EXIST, message: '未配置管理密码' }
296
- }
297
- if (config.ADMIN_PASS !== md5(password)) {
298
- return { code: RES_CODE.PASS_NOT_MATCH, message: '密码错误' }
299
- }
300
- return {
301
- code: RES_CODE.SUCCESS
302
- }
303
- }
304
-
305
- function getSearchKeyword (event) {
306
- if (event.keyword === undefined || event.keyword === null) return ''
307
- if (typeof event.keyword !== 'string') throw new Error('搜索关键词必须是字符串')
308
- const keyword = event.keyword.trim()
309
- if (keyword.length > 100) throw new Error('搜索关键词不能超过 100 个字符')
310
- return keyword
311
- }
312
-
313
- function commentMatchesKeyword (comment, keyword) {
314
- return [comment.nick, comment.comment].some(value =>
315
- typeof value === 'string' && value.toLowerCase().includes(keyword)
316
- )
317
- }
318
-
319
- function escapeRegExp (value) {
320
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
321
- }
322
-
323
- // 读取评论
324
- async function commentGet (event) {
325
- const res = {}
326
- try {
327
- validate(event, ['url'])
328
- if (getSearchKeyword(event)) return commentSearch(event)
329
- const uid = event.accessToken
330
- const isAdminUser = isAdmin(event.accessToken)
331
- const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8
332
- const sort = event.sort || 'newest'
333
- let more = false
334
- let condition
335
- let query
336
- condition = {
337
- url: { $in: getUrlQuery(event.url) },
338
- rid: { $exists: false }
339
- }
340
- // 按当前用户和配置查询可见评论
341
- query = getCommentQuery({ condition, uid, isAdminUser })
342
- // 读取总条数
343
- const count = db
344
- .getCollection('comment')
345
- .count(query)
346
- // 读取主楼
347
- if (event.before) {
348
- condition.created = { $lt: event.before }
349
- }
350
- // 不包含置顶
351
- condition.top = { $ne: true }
352
- query = getCommentQuery({ condition, uid, isAdminUser })
353
-
354
- let sortOrder
355
- if (sort === 'oldest') {
356
- sortOrder = [['created', false]]
357
- } else if (sort === 'popular') {
358
- sortOrder = [['ups', true], ['created', true]]
359
- } else {
360
- sortOrder = [['created', true]]
361
- }
362
-
363
- let main = db
364
- .getCollection('comment')
365
- .chain()
366
- .find(query)
367
- .compoundsort(sortOrder)
368
- // 流式分页,通过多读 1 条的方式,确认是否还有更多评论
369
- .limit(limit + 1)
370
- .data()
371
- if (main.length > limit) {
372
- // 还有更多评论
373
- more = true
374
- // 删除多读的 1 条
375
- main.splice(limit, 1)
376
- }
377
- let top = []
378
- if (!config.TOP_DISABLED && !event.before) {
379
- // 查询置顶评论
380
- query = getCommentQuery({ condition: { ...condition, top: true }, uid, isAdminUser })
381
- top = db
382
- .getCollection('comment')
383
- .chain()
384
- .find(query)
385
- .compoundsort([['created', true]])
386
- .data()
387
- // 合并置顶评论和非置顶评论
388
- main = [
389
- ...top,
390
- ...main
391
- ]
392
- }
393
- condition = {
394
- rid: { $in: main.map((item) => item._id.toString()) }
395
- }
396
- query = getCommentQuery({ condition, uid, isAdminUser })
397
- // 读取回复楼
398
- const reply = db
399
- .getCollection('comment')
400
- .chain()
401
- .find(query)
402
- .data()
403
- res.data = parseComment([...main, ...reply], uid, config)
404
- res.more = more
405
- res.count = count
406
- } catch (e) {
407
- res.data = []
408
- res.message = e.message
409
- }
410
- return res
411
- }
412
-
413
- async function commentSearch (event) {
414
- const res = {}
415
- try {
416
- validate(event, ['url'])
417
- const keyword = getSearchKeyword(event).toLowerCase()
418
- const page = Math.max(parseInt(event.page) || 1, 1)
419
- const uid = event.accessToken
420
- const isAdminUser = isAdmin(event.accessToken)
421
- const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8
422
- const sort = event.sort || 'newest'
423
- let more = false
424
-
425
- const visible = db.getCollection('comment').chain()
426
- .find(getCommentQuery({ condition: { url: { $in: getUrlQuery(event.url) } }, uid, isAdminUser }))
427
- .data()
428
- const matchedRoots = new Set(visible
429
- .filter(comment => commentMatchesKeyword(comment, keyword))
430
- .map(comment => String(comment.rid || comment._id)))
431
- let main = visible.filter(comment => !comment.rid && matchedRoots.has(String(comment._id)))
432
- const count = main.length
433
- let replies = visible
434
-
435
- if (sort === 'oldest') {
436
- main.sort((a, b) => a.created - b.created)
437
- } else if (sort === 'popular') {
438
- main.sort((a, b) => {
439
- const ups = (b.ups || []).length - (a.ups || []).length
440
- return ups || b.created - a.created
441
- })
442
- } else {
443
- main.sort((a, b) => b.created - a.created)
444
- }
445
- let top = []
446
- if (!config.TOP_DISABLED) {
447
- if (page === 1) top = main.filter(comment => comment.top === true)
448
- main = main.filter(comment => comment.top !== true)
449
- }
450
- main = main.slice((page - 1) * limit, page * limit + 1)
451
- if (main.length > limit) {
452
- more = true
453
- main = main.slice(0, limit)
454
- }
455
- main = [...top, ...main]
456
-
457
- const mainIds = new Set(main.map(comment => comment._id.toString()))
458
- replies = replies.filter(comment => mainIds.has(String(comment.rid)))
459
- res.data = parseComment([...main, ...replies], uid, config)
460
- res.more = more
461
- res.count = count
462
- } catch (e) {
463
- res.data = []
464
- res.message = e.message
465
- }
466
- return res
467
- }
468
-
469
- function getCommentQuery ({ condition, uid, isAdminUser }) {
470
- if (config.HIDE_SPAM === 'true') {
471
- return { ...condition, isSpam: { $ne: true } }
472
- }
473
- return {
474
- $or: [
475
- { ...condition, isSpam: { $ne: isAdminUser ? 'imaegoo' : true } },
476
- { ...condition, uid }
477
- ]
478
- }
479
- }
480
-
481
- // 管理员读取评论
482
- async function commentGetForAdmin (event) {
483
- const res = {}
484
- const isAdminUser = isAdmin(event.accessToken)
485
- if (isAdminUser) {
486
- validate(event, ['per', 'page'])
487
- const collection = db
488
- .getCollection('comment')
489
- const condition = getCommentSearchCondition(event)
490
- const count = await collection.count(condition)
491
- const data = await collection
492
- .chain()
493
- .find(condition)
494
- .compoundsort([['created', true]])
495
- .offset(event.per * (event.page - 1))
496
- .limit(event.per)
497
- .data()
498
- res.code = RES_CODE.SUCCESS
499
- res.count = count
500
- res.data = parseCommentForAdmin(data)
501
- } else {
502
- res.code = RES_CODE.NEED_LOGIN
503
- res.message = '请先登录'
504
- }
505
- return res
506
- }
507
-
508
- function getCommentSearchCondition (event) {
509
- let condition
510
- if (event.type) {
511
- switch (event.type) {
512
- case 'VISIBLE':
513
- condition = { isSpam: { $ne: true } }
514
- break
515
- case 'HIDDEN':
516
- condition = { isSpam: true }
517
- break
518
- }
519
- }
520
- const keyword = getSearchKeyword(event)
521
- if (keyword) {
522
- const regExp = {
523
- $regex: escapeRegExp(keyword),
524
- $options: 'i'
525
- }
526
- condition = {
527
- $or: [
528
- { ...condition, nick: regExp },
529
- { ...condition, mail: regExp },
530
- { ...condition, link: regExp },
531
- { ...condition, ip: regExp },
532
- { ...condition, comment: regExp },
533
- { ...condition, url: regExp },
534
- { ...condition, href: regExp }
535
- ]
536
- }
537
- }
538
- return condition
539
- }
540
-
541
- // 管理员修改评论
542
- async function commentSetForAdmin (event) {
543
- const res = {}
544
- const isAdminUser = isAdmin(event.accessToken)
545
- if (isAdminUser) {
546
- validate(event, ['id', 'set'])
547
- db
548
- .getCollection('comment')
549
- .findAndUpdate({ _id: event.id }, (obj) => {
550
- for (const key of Object.keys(event.set)) {
551
- obj[key] = event.set[key]
552
- }
553
- obj.updated = Date.now()
554
- return obj
555
- })
556
- res.code = RES_CODE.SUCCESS
557
- res.updated = 1
558
- } else {
559
- res.code = RES_CODE.NEED_LOGIN
560
- res.message = '请先登录'
561
- }
562
- return res
563
- }
564
-
565
- // 管理员删除评论
566
- async function commentDeleteForAdmin (event) {
567
- const res = {}
568
- const isAdminUser = isAdmin(event.accessToken)
569
- if (isAdminUser) {
570
- validate(event, ['id'])
571
- db
572
- .getCollection('comment')
573
- .findAndRemove({ _id: event.id })
574
- res.code = RES_CODE.SUCCESS
575
- res.deleted = 1
576
- } else {
577
- res.code = RES_CODE.NEED_LOGIN
578
- res.message = '请先登录'
579
- }
580
- return res
581
- }
582
-
583
- // 用户删除自己的评论
584
- async function commentDeleteForUser (event) {
585
- const res = {}
586
- try {
587
- const uid = event.accessToken
588
- await checkCommentOwnership(event.id, uid, (id) => {
589
- return db.getCollection('comment').findOne({ _id: id })
590
- })
591
- db.getCollection('comment').findAndRemove({ _id: event.id })
592
- res.code = RES_CODE.SUCCESS
593
- res.deleted = 1
594
- } catch (e) {
595
- res.code = RES_CODE.FAIL
596
- res.message = e.message
597
- }
598
- return res
599
- }
600
-
601
- // 管理员导入评论
602
- async function commentImportForAdmin (event) {
603
- const res = {}
604
- let logText = ''
605
- const log = (message) => {
606
- logText += `${new Date().toLocaleString()} ${message}\n`
607
- }
608
- const isAdminUser = isAdmin(event.accessToken)
609
- if (isAdminUser) {
610
- try {
611
- validate(event, ['source', 'file'])
612
- log(`开始导入 ${event.source}`)
613
- let comments
614
- switch (event.source) {
615
- case 'valine': {
616
- const valineDb = await readFile(event.file, 'json', log)
617
- comments = await commentImportValine(valineDb, log)
618
- break
619
- }
620
- case 'disqus': {
621
- const disqusDb = await readFile(event.file, 'xml', log)
622
- comments = await commentImportDisqus(disqusDb, log)
623
- break
624
- }
625
- case 'artalk': {
626
- const artalkDb = await readFile(event.file, 'json', log)
627
- comments = await commentImportArtalk(artalkDb, log)
628
- break
629
- }
630
- case 'artalk2': {
631
- const artalkDb = await readFile(event.file, 'json', log)
632
- comments = await commentImportArtalk2(artalkDb, log)
633
- break
634
- }
635
- case 'twikoo': {
636
- const twikooDb = await readFile(event.file, 'json', log)
637
- comments = await commentImportTwikoo(twikooDb, log)
638
- break
639
- }
640
- default:
641
- throw new Error(`不支持 ${event.source} 的导入,请更新 Twikoo 云函数至最新版本`)
642
- }
643
- await bulkSaveComments(comments)
644
- log('导入成功')
645
- } catch (e) {
646
- log(e.message)
647
- }
648
- res.code = RES_CODE.SUCCESS
649
- res.log = logText
650
- logger.info(logText)
651
- } else {
652
- res.code = RES_CODE.NEED_LOGIN
653
- res.message = '请先登录'
654
- }
655
- return res
656
- }
657
-
658
- async function commentExportForAdmin (event) {
659
- const res = {}
660
- const isAdminUser = isAdmin(event.accessToken)
661
- if (isAdminUser) {
662
- const collection = event.collection || 'comment'
663
- const data = db
664
- .getCollection(collection)
665
- .chain()
666
- .find({})
667
- .data({ removeMeta: true })
668
- res.code = RES_CODE.SUCCESS
669
- res.data = data
670
- } else {
671
- res.code = RES_CODE.NEED_LOGIN
672
- res.message = '请先登录'
673
- }
674
- return res
675
- }
676
-
677
- // 读取文件并转为 js object
678
- async function readFile (file, type, log) {
679
- try {
680
- let content = file.toString('utf8')
681
- log('评论文件读取成功')
682
- if (type === 'json') {
683
- content = jsonParse(content)
684
- log('评论文件 JSON 解析成功')
685
- } else if (type === 'xml') {
686
- content = await xml2js.parseStringPromise(content)
687
- log('评论文件 XML 解析成功')
688
- }
689
- return content
690
- } catch (e) {
691
- log(`评论文件读取失败:${e.message}`)
692
- }
693
- }
694
-
695
- // 批量导入评论
696
- async function bulkSaveComments (comments) {
697
- db
698
- .getCollection('comment')
699
- .insert(comments)
700
- }
701
-
702
- // 点赞 / 反对 / 取消
703
- async function commentLike (event) {
704
- const res = {}
705
- validate(event, ['id'])
706
- const type = event.type || 'up'
707
- res.updated = await like(event.id, event.accessToken, type)
708
- return res
709
- }
710
-
711
- // 点赞 / 反对 / 取消
712
- async function like (id, uid, type) {
713
- const record = db
714
- .getCollection('comment')
715
- const comment = await record
716
- .findOne({ _id: id })
717
- const commentData = comment || {}
718
- const ups = commentData.ups || []
719
- const downs = commentData.downs || []
720
-
721
- let newUps = [...ups]
722
- let newDowns = [...downs]
723
-
724
- if (type === 'up') {
725
- if (ups.includes(uid)) {
726
- newUps = ups.filter((item) => item !== uid)
727
- } else {
728
- newUps.push(uid)
729
- newDowns = downs.filter((item) => item !== uid)
730
- }
731
- } else if (type === 'down') {
732
- if (downs.includes(uid)) {
733
- newDowns = downs.filter((item) => item !== uid)
734
- } else {
735
- newDowns.push(uid)
736
- newUps = ups.filter((item) => item !== uid)
737
- }
738
- }
739
-
740
- await record.findAndUpdate({ _id: id }, (obj) => {
741
- obj.ups = newUps
742
- obj.downs = newDowns
743
- return obj
744
- })
745
- return 1
746
- }
747
-
748
- /**
749
- * 提交评论。分为多个步骤
750
- * 1. 参数校验
751
- * 2. 预检测垃圾评论(包括限流、人工审核、违禁词检测等)
752
- * 3. 保存到数据库
753
- * 4. 触发异步任务(包括 IM 通知、邮件通知、第三方垃圾评论检测
754
- * 等,因为这些任务比较耗时,所以要放在另一个线程进行)
755
- * @param {String} event.nick 昵称
756
- * @param {String} event.mail 邮箱
757
- * @param {String} event.link 网址
758
- * @param {String} event.ua UserAgent
759
- * @param {String} event.url 评论页地址
760
- * @param {String} event.comment 评论内容
761
- * @param {String} event.pid 回复的 ID
762
- * @param {String} event.rid 评论楼 ID
763
- */
764
- async function commentSubmit (event, request) {
765
- const res = {}
766
- // 参数校验
767
- validate(event, ['url', 'ua', 'comment'])
768
- // 限流
769
- await limitFilter(request)
770
- // 验证码
771
- await checkCaptcha(event, request)
772
- // 预检测、转换
773
- const data = await parse(event, request)
774
- // 保存
775
- const comment = await save(data)
776
- res.id = comment.id
777
- // 异步垃圾检测、发送评论通知
778
- logger.log('开始异步垃圾检测、发送评论通知')
779
- // 私有部署支持直接异步调用
780
- postSubmit(comment)
781
- return res
782
- }
783
-
784
- // 保存评论
785
- async function save (data) {
786
- db
787
- .getCollection('comment')
788
- .insert(data)
789
- data.id = data._id
790
- return data
791
- }
792
-
793
- async function getParentComment (currentComment) {
794
- const parentComment = db
795
- .getCollection('comment')
796
- .findOne({ _id: currentComment.pid })
797
- return parentComment
798
- }
799
-
800
- // 异步垃圾检测、发送评论通知
801
- async function postSubmit (comment) {
802
- try {
803
- logger.log('POST_SUBMIT')
804
- // 垃圾检测
805
- const isSpam = await postCheckSpam(comment, config)
806
- await saveSpamCheckResult(comment, isSpam)
807
- // 发送通知
808
- await sendNotice(comment, config, getParentComment)
809
- } catch (e) {
810
- logger.warn('POST_SUBMIT 失败', e)
811
- }
812
- }
813
-
814
- // 将评论转为数据库存储格式
815
- async function parse (comment, request) {
816
- const timestamp = Date.now()
817
- const isAdminUser = isAdmin(request.body.accessToken)
818
- const isBloggerMail = equalsMail(comment.mail, config.BLOGGER_EMAIL)
819
- if (isBloggerMail && !isAdminUser) throw new Error('请先登录管理面板,再使用博主身份发送评论')
820
- if (comment.mail && !isValidEmail(comment.mail)) throw new Error('邮箱格式不合法')
821
- const hashMethod = config.GRAVATAR_CDN === 'cravatar.cn' ? md5 : sha256
822
- const commentDo = {
823
- _id: uuidv4().replace(/-/g, ''),
824
- uid: request.body.accessToken,
825
- nick: comment.nick ? comment.nick : '匿名',
826
- mail: comment.mail ? comment.mail : '',
827
- mailMd5: comment.mail ? hashMethod(normalizeMail(comment.mail)) : '',
828
- link: comment.link ? comment.link : '',
829
- ua: comment.ua,
830
- ip: getIp(request),
831
- master: isBloggerMail,
832
- url: comment.url,
833
- href: comment.href,
834
- comment: DOMPurify.sanitize(comment.comment, { FORBID_TAGS: ['style'], FORBID_ATTR: ['style'] }),
835
- pid: comment.pid ? comment.pid : comment.rid,
836
- rid: comment.rid,
837
- isSpam: isAdminUser ? false : preCheckSpam(comment, config),
838
- created: timestamp,
839
- updated: timestamp
840
- }
841
- if (isQQ(comment.mail)) {
842
- commentDo.mail = addQQMailSuffix(comment.mail)
843
- commentDo.mailMd5 = hashMethod(normalizeMail(commentDo.mail))
844
- commentDo.avatar = await getQQAvatar(comment.mail)
845
- }
846
- return commentDo
847
- }
848
-
849
- // 限流
850
- async function limitFilter (request) {
851
- // 限制每个 IP 每 10 分钟发表的评论数量
852
- let limitPerMinute = parseInt(config.LIMIT_PER_MINUTE)
853
- if (Number.isNaN(limitPerMinute)) limitPerMinute = 10
854
- if (limitPerMinute) {
855
- const count = db
856
- .getCollection('comment')
857
- .count({
858
- ip: getIp(request),
859
- created: { $gt: Date.now() - 600000 }
860
- })
861
- if (count > limitPerMinute) {
862
- throw new Error('发言频率过高')
863
- }
864
- }
865
- // 限制所有 IP 每 10 分钟发表的评论数量
866
- let limitPerMinuteAll = parseInt(config.LIMIT_PER_MINUTE_ALL)
867
- if (Number.isNaN(limitPerMinuteAll)) limitPerMinuteAll = 10
868
- if (limitPerMinuteAll) {
869
- const count = db
870
- .getCollection('comment')
871
- .count({
872
- created: { $gt: Date.now() - 600000 }
873
- })
874
- if (count > limitPerMinuteAll) {
875
- throw new Error('评论太火爆啦 >_< 请稍后再试')
876
- }
877
- }
878
- }
879
-
880
- async function checkCaptcha (comment, request) {
881
- logger.log('验证码配置:', {
882
- CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER,
883
- TURNSTILE_SITE_KEY: config.TURNSTILE_SITE_KEY,
884
- GEETEST_CAPTCHA_ID: config.GEETEST_CAPTCHA_ID,
885
- GEETEST_CAPTCHA_KEY: config.GEETEST_CAPTCHA_KEY ? '***' : undefined,
886
- CAP_API_ENDPOINT: config.CAP_API_ENDPOINT
887
- })
888
- const provider = config.CAPTCHA_PROVIDER
889
- if (provider === 'Turnstile' && config.TURNSTILE_SITE_KEY && config.TURNSTILE_SECRET_KEY) {
890
- await checkTurnstileCaptcha({
891
- ip: getIp(request),
892
- turnstileToken: comment.turnstileToken,
893
- turnstileTokenSecretKey: config.TURNSTILE_SECRET_KEY
894
- })
895
- } else if (provider === 'Geetest' && config.GEETEST_CAPTCHA_ID && config.GEETEST_CAPTCHA_KEY) {
896
- await checkGeeTestCaptcha({
897
- geeTestCaptchaId: config.GEETEST_CAPTCHA_ID,
898
- geeTestCaptchaKey: config.GEETEST_CAPTCHA_KEY,
899
- geeTestLotNumber: comment.geeTestLotNumber,
900
- geeTestCaptchaOutput: comment.geeTestCaptchaOutput,
901
- geeTestPassToken: comment.geeTestPassToken,
902
- geeTestGenTime: comment.geeTestGenTime
903
- })
904
- } else if (provider === 'Cap' && isBuiltinCap(config)) {
905
- if (!comment.capToken) {
906
- throw new Error('验证码 token 缺失,请刷新页面重试')
907
- }
908
- await checkCapCaptcha({
909
- capToken: comment.capToken,
910
- cap: createCap(lokiStorage(db))
911
- })
912
- } else if (provider === 'Cap' && config.CAP_API_ENDPOINT && config.CAP_SECRET_KEY) {
913
- if (!comment.capToken) {
914
- throw new Error('验证码 token 缺失,请刷新页面重试')
915
- }
916
- await checkCapCaptcha({
917
- capToken: comment.capToken,
918
- capSecretKey: config.CAP_SECRET_KEY,
919
- capApiEndpoint: config.CAP_API_ENDPOINT
920
- })
921
- } else if (provider === 'Cap') {
922
- throw new Error('Cap 验证码配置不完整:内嵌模式无需额外配置,外部模式需填写 CAP_API_ENDPOINT 与 CAP_SECRET_KEY')
923
- } else if (provider) {
924
- throw new Error(`不支持的验证码类型: ${provider}`)
925
- }
926
- }
927
-
928
- async function saveSpamCheckResult (comment, isSpam) {
929
- comment.isSpam = isSpam
930
- if (isSpam) {
931
- db
932
- .getCollection('comment')
933
- .findAndUpdate({ created: comment.created }, (obj) => {
934
- obj.isSpam = isSpam
935
- obj.updated = Date.now()
936
- return obj
937
- })
938
- }
939
- }
940
-
941
- /**
942
- * 获取文章点击量
943
- * @param {String} event.url 文章地址
944
- */
945
- async function counterGet (event) {
946
- const res = {}
947
- try {
948
- validate(event, ['url'])
949
- const record = await readCounter(event.url)
950
- res.data = record || {}
951
- res.time = res.data ? res.data.time : 0
952
- res.updated = await incCounter(event)
953
- } catch (e) {
954
- res.message = e.message
955
- return res
956
- }
957
- return res
958
- }
959
-
960
- // 读取阅读数
961
- async function readCounter (url) {
962
- return db
963
- .getCollection('counter')
964
- .findOne({ url })
965
- }
966
-
967
- /**
968
- * 更新阅读数
969
- * @param {String} event.url 文章地址
970
- * @param {String} event.title 文章标题
971
- */
972
- async function incCounter (event) {
973
- const counter = db.getCollection('counter')
974
- const result = counter.find({ url: event.url })[0]
975
- if (result) {
976
- result.time = result.time ? result.time + 1 : 1
977
- result.title = event.title
978
- result.updated = Date.now()
979
- counter.update(result)
980
- } else {
981
- counter.insert({
982
- url: event.url,
983
- title: event.title,
984
- time: 1,
985
- created: Date.now(),
986
- updated: Date.now()
987
- })
988
- }
989
- return 1
990
- }
991
-
992
- /**
993
- * 批量获取文章评论数 API
994
- * @param {Array} event.urls 不包含协议和域名的文章路径列表,必传参数
995
- * @param {Boolean} event.includeReply 评论数是否包括回复,默认:false
996
- */
997
- async function getCommentsCount (event) {
998
- const res = {}
999
- try {
1000
- validate(event, ['urls'])
1001
- const query = {}
1002
- query.isSpam = { $ne: true }
1003
- query.url = { $in: getUrlsQuery(event.urls) }
1004
- if (!event.includeReply) {
1005
- query.rid = { $exists: false }
1006
- }
1007
- res.data = []
1008
- const commentCollection = db.getCollection('comment')
1009
- for (const url of event.urls) {
1010
- const record = commentCollection.count({ ...query, url })
1011
- res.data.push({
1012
- url,
1013
- count: record || 0
1014
- })
1015
- }
1016
- } catch (e) {
1017
- res.message = e.message
1018
- return res
1019
- }
1020
- return res
1021
- }
1022
-
1023
- /**
1024
- * 获取最新评论 API
1025
- * @param {Boolean} event.includeReply 评论数是否包括回复,默认:false
1026
- */
1027
- async function getRecentComments (event) {
1028
- const res = {}
1029
- try {
1030
- const query = {}
1031
- query.isSpam = { $ne: true }
1032
- if (event.urls && event.urls.length) {
1033
- query.url = { $in: getUrlsQuery(event.urls) }
1034
- }
1035
- if (!event.includeReply) query.rid = { $exists: false }
1036
- if (event.pageSize > 100) event.pageSize = 100
1037
- const result = db
1038
- .getCollection('comment')
1039
- .chain()
1040
- .find(query)
1041
- .compoundsort([['created', true]])
1042
- .limit(event.pageSize || 10)
1043
- .data()
1044
- res.data = result.map((comment) => {
1045
- return {
1046
- id: comment._id.toString(),
1047
- url: comment.url,
1048
- nick: comment.nick,
1049
- avatar: getAvatar(comment, config),
1050
- mailMd5: getMailMd5(comment),
1051
- link: comment.link,
1052
- comment: comment.comment,
1053
- commentText: htmlToText(comment.comment),
1054
- created: comment.created
1055
- }
1056
- })
1057
- } catch (e) {
1058
- res.message = e.message
1059
- return res
1060
- }
1061
- return res
1062
- }
1063
-
1064
- // 获取 QQ 昵称
1065
- async function qqNickGet (event) {
1066
- const res = {}
1067
- try {
1068
- validate(event, ['qq'])
1069
- const nick = await getQQNick(event.qq, config.QQ_API_KEY)
1070
- res.code = RES_CODE.SUCCESS
1071
- res.nick = nick
1072
- } catch (e) {
1073
- res.code = RES_CODE.FAIL
1074
- res.message = e.message
1075
- }
1076
- return res
1077
- }
1078
-
1079
- // 修改配置
1080
- async function setConfig (event) {
1081
- const isAdminUser = isAdmin(event.accessToken)
1082
- if (isAdminUser) {
1083
- writeConfig(event.config)
1084
- return {
1085
- code: RES_CODE.SUCCESS
1086
- }
1087
- } else {
1088
- return {
1089
- code: RES_CODE.NEED_LOGIN,
1090
- message: '请先登录'
1091
- }
1092
- }
1093
- }
1094
-
1095
- function protect (request) {
1096
- // 防御
1097
- const ip = getIp(request)
1098
- requestTimes[ip] = (requestTimes[ip] || 0) + 1
1099
- if (requestTimes[ip] > MAX_REQUEST_TIMES) {
1100
- logger.warn(`${ip} 当前请求次数为 ${requestTimes[ip]},已超过最大请求次数`)
1101
- throw new Error('Too Many Requests')
1102
- } else {
1103
- logger.log(`${ip} 当前请求次数为 ${requestTimes[ip]}`)
1104
- }
1105
- }
1106
-
1107
- // 读取配置
1108
- async function readConfig () {
1109
- try {
1110
- const res = db
1111
- .getCollection('config')
1112
- .findOne({})
1113
- config = res || {}
1114
- return config
1115
- } catch (e) {
1116
- logger.error('读取配置失败:', e)
1117
- await createCollections()
1118
- config = {}
1119
- return config
1120
- }
1121
- }
1122
-
1123
- // 写入配置
1124
- async function writeConfig (newConfig) {
1125
- if (!Object.keys(newConfig).length) return 0
1126
- logger.info('写入配置:', newConfig)
1127
- try {
1128
- const oldConfig = db
1129
- .getCollection('config')
1130
- .chain()
1131
- .find({})
1132
- .limit(1)
1133
- .data()[0]
1134
- if (oldConfig) {
1135
- db.getCollection('config').update({
1136
- ...oldConfig,
1137
- ...newConfig
1138
- })
1139
- } else {
1140
- db.getCollection('config').insert(newConfig)
1141
- }
1142
- // 更新后重置配置缓存
1143
- config = null
1144
- return 1
1145
- } catch (e) {
1146
- logger.error('写入配置失败:', e)
1147
- return null
1148
- }
1149
- }
1150
-
1151
- // 判断用户是否管理员
1152
- function isAdmin (accessToken) {
1153
- return config.ADMIN_PASS === md5(accessToken)
1154
- }
1155
-
1156
- // 建立数据库 collections
1157
- async function createCollections () {
1158
- const collections = ['comment', 'config', 'counter', 'cap_challenges', 'cap_tokens']
1159
- const res = {}
1160
- for (const collection of collections) {
1161
- if (db.getCollection(collection) === null) {
1162
- db.addCollection(collection)
1163
- }
1164
- }
1165
- return res
1166
- }
1167
-
1168
- async function capChallenge () {
1169
- if (!isBuiltinCap(config)) {
1170
- return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
1171
- }
1172
- const data = await createChallenge(createCap(lokiStorage(db)))
1173
- return { code: RES_CODE.SUCCESS, ...data }
1174
- }
1175
-
1176
- async function capRedeem (event) {
1177
- if (!isBuiltinCap(config)) {
1178
- return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
1179
- }
1180
- const data = await redeemChallenge(createCap(lokiStorage(db)), event)
1181
- return { code: RES_CODE.SUCCESS, ...data }
1182
- }
1183
-
1184
- function getIp (request) {
1185
- try {
1186
- const { TWIKOO_IP_HEADERS } = process.env
1187
- const headers = TWIKOO_IP_HEADERS ? JSON.parse(TWIKOO_IP_HEADERS) : []
1188
- return getUserIP(request, headers)
1189
- } catch (e) {
1190
- logger.error('获取 IP 错误信息:', e)
1191
- }
1192
- return getUserIP(request)
1193
- }
1194
-
1195
- async function closeDatabase () {
1196
- if (!db) return
1197
- try {
1198
- await new Promise((resolve, reject) => {
1199
- db.saveDatabase((err) => {
1200
- if (err) {
1201
- reject(err)
1202
- } else {
1203
- resolve()
1204
- }
1205
- })
1206
- })
1207
- } finally {
1208
- db.close()
1209
- db = null
1210
- }
1211
- }
1212
-
1213
- async function shutdown () {
1214
- if (requestTimesTimer) {
1215
- clearInterval(requestTimesTimer)
1216
- requestTimesTimer = null
1217
- }
1218
- await closeDatabase()
1219
- }
1220
-
1221
- function clearRequestTimes () {
1222
- requestTimes = {}
1223
- }
1224
-
1225
- requestTimesTimer = setInterval(clearRequestTimes, TWIKOO_REQ_TIMES_CLEAR_TIME)
1226
-
1227
- module.exports.shutdown = shutdown