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