tkserver 1.7.23 → 2.0.0-beta.1

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