twikoo-func 1.5.11 → 1.6.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/index.js CHANGED
@@ -4,25 +4,41 @@
4
4
  * Released under the MIT License.
5
5
  */
6
6
 
7
- // 三方依赖 / 3rd party dependencies
8
7
  const { version: VERSION } = require('./package.json')
9
8
  const tcb = require('@cloudbase/node-sdk') // 云开发 SDK
10
- const md5 = require('blueimp-md5') // MD5 加解密
11
- const bowser = require('bowser') // UserAgent 格式化
12
- const nodemailer = require('nodemailer') // 发送邮件
13
- const axios = require('axios') // 发送 REST 请求
14
- const $ = require('cheerio') // jQuery 服务器版
15
- const { AkismetClient } = require('akismet-api') // 反垃圾 API
16
- const createDOMPurify = require('dompurify') // 反 XSS
17
- const { JSDOM } = require('jsdom') // document.window 服务器版
18
- const xml2js = require('xml2js') // XML 解析
19
- const marked = require('marked') // Markdown 解析
20
- const CryptoJS = require('crypto-js') // 编解码
21
- const tencentcloud = require('tencentcloud-sdk-nodejs') // 腾讯云 API NODEJS SDK
22
- const fs = require('fs')
23
- const FormData = require('form-data') // 图片上传
24
- const pushoo = require('pushoo').default // 即时消息通知
25
- const ipToRegion = require('dy-node-ip2region') // IP 属地查询
9
+ const {
10
+ $,
11
+ JSDOM,
12
+ createDOMPurify,
13
+ md5,
14
+ xml2js
15
+ } = require('./utils/lib')
16
+ const {
17
+ getFuncVersion,
18
+ getUrlQuery,
19
+ getUrlsQuery,
20
+ parseComment,
21
+ parseCommentForAdmin,
22
+ getAvatar,
23
+ isQQ,
24
+ addQQMailSuffix,
25
+ getQQAvatar,
26
+ getPasswordStatus,
27
+ preCheckSpam,
28
+ getConfig,
29
+ getConfigForAdmin,
30
+ validate
31
+ } = require('./utils')
32
+ const {
33
+ jsonParse,
34
+ commentImportValine,
35
+ commentImportDisqus,
36
+ commentImportArtalk,
37
+ commentImportTwikoo
38
+ } = require('./utils/import')
39
+ const { postCheckSpam } = require('./utils/spam')
40
+ const { sendNotice, emailTest } = require('./utils/notify')
41
+ const { uploadImage } = require('./utils/image')
26
42
 
27
43
  // 云函数 SDK / tencent cloudbase sdk
28
44
  const app = tcb.init({ env: tcb.SYMBOL_CURRENT_ENV })
@@ -34,38 +50,19 @@ const _ = db.command
34
50
  const window = new JSDOM('').window
35
51
  const DOMPurify = createDOMPurify(window)
36
52
 
37
- // 初始化 IP 属地
38
- const ipRegionSearcher = ipToRegion.create()
39
-
40
53
  // 常量 / constants
41
- const RES_CODE = {
42
- SUCCESS: 0,
43
- FAIL: 1000,
44
- EVENT_NOT_EXIST: 1001,
45
- PASS_EXIST: 1010,
46
- CONFIG_NOT_EXIST: 1020,
47
- CREDENTIALS_NOT_EXIST: 1021,
48
- CREDENTIALS_INVALID: 1025,
49
- PASS_NOT_EXIST: 1022,
50
- PASS_NOT_MATCH: 1023,
51
- NEED_LOGIN: 1024,
52
- FORBIDDEN: 1403,
53
- AKISMET_ERROR: 1030,
54
- UPLOAD_FAILED: 1040
55
- }
54
+ const { RES_CODE, MAX_REQUEST_TIMES } = require('./utils/constants')
56
55
  const ADMIN_USER_ID = 'admin'
57
- const MAX_REQUEST_TIMES = parseInt(process.env.TWIKOO_THROTTLE) || 250
58
56
 
59
57
  // 全局变量 / variables
60
58
  // 警告:全局定义的变量,会被云函数缓存,请慎重定义全局变量
61
59
  // 参考 https://docs.cloudbase.net/cloud-function/deep-principle.html 中的 “实例复用”
62
60
  let config
63
- let transporter
64
61
  const requestTimes = {}
65
62
 
66
63
  // 云函数入口点 / entry point
67
64
  exports.main = async (event, context) => {
68
- console.log('请求IP:', auth.getClientIP())
65
+ console.log('请求 IP:', auth.getClientIP())
69
66
  console.log('请求方法:', event.event)
70
67
  console.log('请求参数:', event)
71
68
  let res = {}
@@ -74,7 +71,7 @@ exports.main = async (event, context) => {
74
71
  await readConfig()
75
72
  switch (event.event) {
76
73
  case 'GET_FUNC_VERSION':
77
- res = getFuncVersion()
74
+ res = getFuncVersion({ VERSION })
78
75
  break
79
76
  case 'COMMENT_GET':
80
77
  res = await commentGet(event)
@@ -104,16 +101,16 @@ exports.main = async (event, context) => {
104
101
  res = await counterGet(event)
105
102
  break
106
103
  case 'GET_PASSWORD_STATUS':
107
- res = await getPasswordStatus()
104
+ res = await getPasswordStatus(config, VERSION)
108
105
  break
109
106
  case 'SET_PASSWORD':
110
107
  res = await setPassword(event)
111
108
  break
112
109
  case 'GET_CONFIG':
113
- res = getConfig()
110
+ res = getConfig({ config, VERSION, isAdmin: await isAdmin() })
114
111
  break
115
112
  case 'GET_CONFIG_FOR_ADMIN':
116
- res = await getConfigForAdmin()
113
+ res = await getConfigForAdmin({ config, isAdmin: await isAdmin() })
117
114
  break
118
115
  case 'SET_CONFIG':
119
116
  res = await setConfig(event)
@@ -128,10 +125,10 @@ exports.main = async (event, context) => {
128
125
  res = await getRecentComments(event)
129
126
  break
130
127
  case 'EMAIL_TEST': // >= 1.4.6
131
- res = await emailTest(event)
128
+ res = await emailTest(event, config, await isAdmin())
132
129
  break
133
130
  case 'UPLOAD_IMAGE': // >= 1.5.0
134
- res = await uploadImage(event)
131
+ res = await uploadImage(event, config)
135
132
  break
136
133
  default:
137
134
  if (event.event) {
@@ -153,24 +150,6 @@ exports.main = async (event, context) => {
153
150
  return res
154
151
  }
155
152
 
156
- // 获取 Twikoo 云函数版本
157
- function getFuncVersion () {
158
- return {
159
- code: RES_CODE.SUCCESS,
160
- version: VERSION
161
- }
162
- }
163
-
164
- // 判断是否存在管理员密码
165
- async function getPasswordStatus () {
166
- return {
167
- code: RES_CODE.SUCCESS,
168
- status: !!config.ADMIN_PASS,
169
- credentials: !!config.CREDENTIALS,
170
- version: VERSION
171
- }
172
- }
173
-
174
153
  // 写入管理密码
175
154
  async function setPassword (event) {
176
155
  const isAdminUser = await isAdmin()
@@ -305,7 +284,7 @@ async function commentGet (event) {
305
284
  .collection('comment')
306
285
  .where(query)
307
286
  .get()
308
- res.data = parseComment([...main.data, ...reply.data], uid)
287
+ res.data = parseComment([...main.data, ...reply.data], uid, config)
309
288
  res.more = more
310
289
  res.count = count.total
311
290
  } catch (e) {
@@ -322,72 +301,6 @@ function getCommentQuery ({ condition, uid, isAdminUser }) {
322
301
  )
323
302
  }
324
303
 
325
- // 同时查询 /path 和 /path/ 的评论
326
- function getUrlQuery (url) {
327
- const variantUrl = url[url.length - 1] === '/' ? url.substring(0, url.length - 1) : `${url}/`
328
- return [url, variantUrl]
329
- }
330
-
331
- // 筛除隐私字段,拼接回复列表
332
- function parseComment (comments, uid) {
333
- const result = []
334
- for (const comment of comments) {
335
- if (!comment.rid) {
336
- const replies = comments
337
- .filter((item) => item.rid === comment._id)
338
- .map((item) => toCommentDto(item, uid, [], comments))
339
- .sort((a, b) => a.created - b.created)
340
- result.push(toCommentDto(comment, uid, replies))
341
- }
342
- }
343
- return result
344
- }
345
-
346
- // 将评论记录转换为前端需要的格式
347
- function toCommentDto (comment, uid, replies = [], comments = []) {
348
- let displayOs = ''
349
- let displayBrowser = ''
350
- if (config.SHOW_UA !== 'false') {
351
- try {
352
- const ua = bowser.getParser(comment.ua)
353
- const os = ua.getOS()
354
- displayOs = [os.name, os.versionName ? os.versionName : os.version].join(' ')
355
- displayBrowser = [ua.getBrowserName(), ua.getBrowserVersion()].join(' ')
356
- } catch (e) {
357
- console.log('bowser 错误:', e)
358
- }
359
- }
360
- const showRegion = !!config.SHOW_REGION && config.SHOW_REGION !== 'false'
361
- return {
362
- id: comment._id,
363
- nick: comment.nick,
364
- avatar: comment.avatar,
365
- mailMd5: comment.mailMd5 || md5(comment.mail),
366
- link: comment.link,
367
- comment: comment.comment,
368
- os: displayOs,
369
- browser: displayBrowser,
370
- ipRegion: showRegion ? getIpRegion({ ip: comment.ip }) : '',
371
- master: comment.master,
372
- like: comment.like ? comment.like.length : 0,
373
- liked: comment.like ? comment.like.findIndex((item) => item === uid) > -1 : false,
374
- replies: replies,
375
- rid: comment.rid,
376
- pid: comment.pid,
377
- ruser: ruser(comment.pid, comments),
378
- top: comment.top,
379
- isSpam: comment.isSpam,
380
- created: comment.created,
381
- updated: comment.updated
382
- }
383
- }
384
-
385
- // 获取回复人昵称 / Get replied user nick name
386
- function ruser (pid, comments = []) {
387
- const comment = comments.find((item) => item._id === pid)
388
- return comment ? comment.nick : null
389
- }
390
-
391
304
  // 管理员读取评论
392
305
  async function commentGetForAdmin (event) {
393
306
  const res = {}
@@ -446,13 +359,6 @@ function getCommentSearchCondition (event) {
446
359
  return condition
447
360
  }
448
361
 
449
- function parseCommentForAdmin (comments) {
450
- for (const comment of comments) {
451
- comment.ipRegion = getIpRegion({ ip: comment.ip, detail: true })
452
- }
453
- return comments
454
- }
455
-
456
362
  // 管理员修改评论
457
363
  async function commentSetForAdmin (event) {
458
364
  const res = {}
@@ -506,30 +412,33 @@ async function commentImportForAdmin (event) {
506
412
  try {
507
413
  validate(event, ['source', 'fileId'])
508
414
  log(`开始导入 ${event.source}`)
415
+ let comments
509
416
  switch (event.source) {
510
417
  case 'valine': {
511
418
  const valineDb = await readFile(event.fileId, 'json', log)
512
- await commentImportValine(valineDb, log)
419
+ comments = await commentImportValine(valineDb, log)
513
420
  break
514
421
  }
515
422
  case 'disqus': {
516
423
  const disqusDb = await readFile(event.fileId, 'xml', log)
517
- await commentImportDisqus(disqusDb, log)
424
+ comments = await commentImportDisqus(disqusDb, log)
518
425
  break
519
426
  }
520
427
  case 'artalk': {
521
428
  const artalkDb = await readFile(event.fileId, 'json', log)
522
- await commentImportArtalk(artalkDb, log)
429
+ comments = await commentImportArtalk(artalkDb, log)
523
430
  break
524
431
  }
525
432
  case 'twikoo': {
526
433
  const twikooDb = await readFile(event.fileId, 'json', log)
527
- await commentImportTwikoo(twikooDb, log)
434
+ comments = await commentImportTwikoo(twikooDb, log)
528
435
  break
529
436
  }
530
437
  default:
531
438
  throw new Error(`不支持 ${event.source} 的导入,请更新 Twikoo 云函数至最新版本`)
532
439
  }
440
+ const ids = await bulkSaveComments(comments)
441
+ log(`导入成功 ${ids.length} 条评论`)
533
442
  // 删除导入完成的文件
534
443
  await app.deleteFile({ fileList: [event.fileId] })
535
444
  } catch (e) {
@@ -565,230 +474,6 @@ async function readFile (fileId, type, log) {
565
474
  }
566
475
  }
567
476
 
568
- // 兼容 Leancloud 两种 JSON 导出格式
569
- function jsonParse (content) {
570
- try {
571
- return JSON.parse(content)
572
- } catch (e1) {
573
- const results = []
574
- const lines = content.split('\n')
575
- for (const line of lines) {
576
- // 逐行 JSON.parse
577
- try {
578
- results.push(JSON.parse(line))
579
- } catch (e2) {}
580
- }
581
- return { results }
582
- }
583
- }
584
-
585
- // Valine 导入
586
- async function commentImportValine (valineDb, log) {
587
- let arr
588
- if (valineDb instanceof Array) {
589
- arr = valineDb
590
- } else if (valineDb && valineDb.results) {
591
- arr = valineDb.results
592
- }
593
- if (!arr) {
594
- log('Valine 评论文件格式有误')
595
- return
596
- }
597
- const comments = []
598
- log(`共 ${arr.length} 条评论`)
599
- for (const comment of arr) {
600
- try {
601
- const parsed = {
602
- _id: comment.objectId,
603
- nick: comment.nick,
604
- ip: comment.ip,
605
- mail: comment.mail,
606
- mailMd5: comment.mailMd5,
607
- isSpam: comment.isSpam,
608
- ua: comment.ua || '',
609
- link: comment.link,
610
- pid: comment.pid,
611
- rid: comment.rid,
612
- master: false,
613
- comment: comment.comment,
614
- url: comment.url,
615
- created: new Date(comment.createdAt).getTime(),
616
- updated: new Date(comment.updatedAt).getTime()
617
- }
618
- comments.push(parsed)
619
- log(`${comment.objectId} 解析成功`)
620
- } catch (e) {
621
- log(`${comment.objectId} 解析失败:${e.message}`)
622
- }
623
- }
624
- log(`解析成功 ${comments.length} 条评论`)
625
- const ids = await bulkSaveComments(comments)
626
- log(`导入成功 ${ids.length} 条评论`)
627
- return comments
628
- }
629
-
630
- // Disqus 导入
631
- async function commentImportDisqus (disqusDb, log) {
632
- if (!disqusDb || !disqusDb.disqus || !disqusDb.disqus.thread || !disqusDb.disqus.post) {
633
- log('Disqus 评论文件格式有误')
634
- return
635
- }
636
- const comments = []
637
- const getParent = (post) => {
638
- return post.parent ? disqusDb.disqus.post.find((item) => item.$['dsq:id'] === post.parent[0].$['dsq:id']) : null
639
- }
640
- let threads = []
641
- try {
642
- threads = disqusDb.disqus.thread.map((thread) => {
643
- return {
644
- id: thread.$['dsq:id'],
645
- title: thread.title[0],
646
- url: thread.id[0],
647
- href: thread.link[0]
648
- }
649
- })
650
- } catch (e) {
651
- log(`无法读取 thread:${e.message}`)
652
- return
653
- }
654
- log(`共 ${disqusDb.disqus.post.length} 条评论`)
655
- for (const post of disqusDb.disqus.post) {
656
- try {
657
- const threadId = post.thread[0].$['dsq:id']
658
- const thread = threads.find((item) => item.id === threadId)
659
- const parent = getParent(post)
660
- let root
661
- if (parent) {
662
- let grandParent = parent
663
- while (true) {
664
- if (grandParent) root = grandParent
665
- else break
666
- grandParent = getParent(grandParent)
667
- }
668
- }
669
- comments.push({
670
- _id: post.$['dsq:id'],
671
- nick: post.author[0].name[0],
672
- mail: '',
673
- link: '',
674
- url: thread.url
675
- ? thread.url.indexOf('http') === 0
676
- ? getRelativeUrl(thread.url)
677
- : thread.url
678
- : getRelativeUrl(thread.href),
679
- href: thread.href,
680
- comment: post.message[0],
681
- ua: '',
682
- ip: '',
683
- isSpam: post.isSpam[0] === 'true' || post.isDeleted[0] === 'true',
684
- master: false,
685
- pid: parent ? parent.$['dsq:id'] : null,
686
- rid: root ? root.$['dsq:id'] : null,
687
- created: new Date(post.createdAt[0]).getTime(),
688
- updated: Date.now()
689
- })
690
- log(`${post.$['dsq:id']} 解析成功`)
691
- } catch (e) {
692
- log(`${post.$['dsq:id']} 解析失败:${e.message}`)
693
- }
694
- }
695
- log(`解析成功 ${comments.length} 条评论`)
696
- const ids = await bulkSaveComments(comments)
697
- log(`导入成功 ${ids.length} 条评论`)
698
- return comments
699
- }
700
-
701
- function getRelativeUrl (url) {
702
- let x = url.indexOf('/')
703
- for (let i = 0; i < 2; i++) {
704
- x = url.indexOf('/', x + 1)
705
- }
706
- return url.substring(x)
707
- }
708
-
709
- // Artalk 导入
710
- async function commentImportArtalk (artalkDb, log) {
711
- const comments = []
712
- if (!artalkDb || !artalkDb.length) {
713
- log('Artalk 评论文件格式有误')
714
- return
715
- }
716
- marked.setOptions({
717
- renderer: new marked.Renderer(),
718
- gfm: true,
719
- tables: true,
720
- breaks: true,
721
- pedantic: false,
722
- sanitize: true,
723
- smartLists: true,
724
- smartypants: true
725
- })
726
- log(`共 ${artalkDb.length} 条评论`)
727
- for (const comment of artalkDb) {
728
- try {
729
- const parsed = {
730
- _id: `artalk${comment.id}`,
731
- nick: comment.nick,
732
- ip: comment.ip,
733
- mail: comment.email,
734
- mailMd5: md5(comment.email),
735
- isSpam: false,
736
- ua: comment.ua || '',
737
- link: comment.link,
738
- pid: comment.rid ? `artalk${comment.rid}` : '',
739
- rid: comment.rid ? `artalk${comment.rid}` : '',
740
- master: false,
741
- comment: DOMPurify.sanitize(marked(comment.content)),
742
- url: getRelativeUrl(comment.page_key),
743
- href: comment.page_key,
744
- created: new Date(comment.date).getTime(),
745
- updated: Date.now()
746
- }
747
- comments.push(parsed)
748
- log(`${comment.id} 解析成功`)
749
- } catch (e) {
750
- log(`${comment.id} 解析失败:${e.message}`)
751
- }
752
- }
753
- log(`解析成功 ${comments.length} 条评论`)
754
- const ids = await bulkSaveComments(comments)
755
- log(`导入成功 ${ids.length} 条评论`)
756
- return comments
757
- }
758
-
759
- // Twikoo 导入
760
- async function commentImportTwikoo (twikooDb, log) {
761
- let arr
762
- if (twikooDb instanceof Array) {
763
- arr = twikooDb
764
- } else if (twikooDb && twikooDb.results) {
765
- arr = twikooDb.results
766
- }
767
- if (!arr) {
768
- log('Valine 评论文件格式有误')
769
- return
770
- }
771
- const comments = []
772
- log(`共 ${arr.length} 条评论`)
773
- for (const comment of arr) {
774
- try {
775
- const parsed = comment
776
- if (comment._id.$oid) {
777
- // 解决 id 历史数据问题
778
- parsed._id = comment._id.$oid
779
- }
780
- comments.push(parsed)
781
- log(`${comment.id} 解析成功`)
782
- } catch (e) {
783
- log(`${comment.id} 解析失败:${e.message}`)
784
- }
785
- }
786
- log(`解析成功 ${comments.length} 条评论`)
787
- const ids = await bulkSaveComments(comments)
788
- log(`导入成功 ${ids.length} 条评论`)
789
- return comments
790
- }
791
-
792
477
  // 批量导入评论
793
478
  async function bulkSaveComments (comments) {
794
479
  const batchRes = await db
@@ -878,239 +563,25 @@ async function save (data) {
878
563
  return data
879
564
  }
880
565
 
566
+ async function getParentComment (currentComment) {
567
+ const parentComment = await db
568
+ .collection('comment')
569
+ .where({ _id: currentComment.pid })
570
+ .get()
571
+ return parentComment.data[0]
572
+ }
573
+
881
574
  // 异步垃圾检测、发送评论通知
882
575
  async function postSubmit (comment, context) {
883
576
  if (!isRecursion(context)) return { code: RES_CODE.FORBIDDEN }
884
577
  // 垃圾检测
885
- await postCheckSpam(comment)
578
+ const isSpam = await postCheckSpam(comment)
579
+ await saveSpamCheckResult(comment, isSpam)
886
580
  // 发送通知
887
- await sendNotice(comment)
581
+ await sendNotice(comment, config, getParentComment)
888
582
  return { code: RES_CODE.SUCCESS }
889
583
  }
890
584
 
891
- // 发送通知
892
- async function sendNotice (comment) {
893
- if (comment.isSpam && config.NOTIFY_SPAM === 'false') return
894
- await Promise.all([
895
- noticeMaster(comment),
896
- noticeReply(comment),
897
- noticePushoo(comment)
898
- ]).catch(err => {
899
- console.error('通知异常:', err)
900
- })
901
- }
902
-
903
- // 初始化邮件插件
904
- async function initMailer ({ throwErr = false } = {}) {
905
- try {
906
- if (!config || !config.SMTP_USER || !config.SMTP_PASS) {
907
- throw new Error('数据库配置不存在')
908
- }
909
- const transportConfig = {
910
- auth: {
911
- user: config.SMTP_USER,
912
- pass: config.SMTP_PASS
913
- }
914
- }
915
- if (config.SMTP_SERVICE) {
916
- transportConfig.service = config.SMTP_SERVICE
917
- } else if (config.SMTP_HOST) {
918
- transportConfig.host = config.SMTP_HOST
919
- transportConfig.port = parseInt(config.SMTP_PORT)
920
- transportConfig.secure = config.SMTP_SECURE === 'true'
921
- } else {
922
- throw new Error('SMTP 服务器没有配置')
923
- }
924
- transporter = nodemailer.createTransport(transportConfig)
925
- try {
926
- const success = await transporter.verify()
927
- if (success) console.log('SMTP 邮箱配置正常')
928
- } catch (error) {
929
- throw new Error('SMTP 邮箱配置异常:', error)
930
- }
931
- return true
932
- } catch (e) {
933
- console.error('邮件初始化异常:', e.message)
934
- if (throwErr) throw e
935
- return false
936
- }
937
- }
938
-
939
- // 博主通知
940
- async function noticeMaster (comment) {
941
- if (!transporter) if (!await initMailer()) return
942
- if (config.BLOGGER_EMAIL === comment.mail) return
943
- // 判断是否存在即时消息推送配置
944
- const hasIMPushConfig = config.PUSHOO_CHANNEL && config.PUSHOO_TOKEN
945
- // 存在即时消息推送配置,则默认不发送邮件给博主
946
- if (hasIMPushConfig && config.SC_MAIL_NOTIFY !== 'true') return
947
- const SITE_NAME = config.SITE_NAME
948
- const NICK = comment.nick
949
- const IMG = getAvatar(comment)
950
- const IP = comment.ip
951
- const MAIL = comment.mail
952
- const COMMENT = comment.comment
953
- const SITE_URL = config.SITE_URL
954
- const POST_URL = appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
955
- const emailSubject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}上有新评论了`
956
- let emailContent
957
- if (config.MAIL_TEMPLATE_ADMIN) {
958
- emailContent = config.MAIL_TEMPLATE_ADMIN
959
- .replace(/\${SITE_URL}/g, SITE_URL)
960
- .replace(/\${SITE_NAME}/g, SITE_NAME)
961
- .replace(/\${NICK}/g, NICK)
962
- .replace(/\${IMG}/g, IMG)
963
- .replace(/\${IP}/g, IP)
964
- .replace(/\${MAIL}/g, MAIL)
965
- .replace(/\${COMMENT}/g, COMMENT)
966
- .replace(/\${POST_URL}/g, POST_URL)
967
- } else {
968
- emailContent = `
969
- <div style="border-top:2px solid #12addb;box-shadow:0 1px 3px #aaaaaa;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;">
970
- <h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">
971
- 您在<a style="text-decoration:none;color: #12addb;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的文章有了新的评论
972
- </h2>
973
- <p><strong>${NICK}</strong>回复说:</p>
974
- <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div>
975
- <p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a><br></p>
976
- </div>`
977
- }
978
- let sendResult
979
- try {
980
- sendResult = await transporter.sendMail({
981
- from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`,
982
- to: config.BLOGGER_EMAIL || config.SENDER_EMAIL,
983
- subject: emailSubject,
984
- html: emailContent
985
- })
986
- } catch (e) {
987
- sendResult = e
988
- }
989
- console.log('博主通知结果:', sendResult)
990
- return sendResult
991
- }
992
-
993
- // 即时消息通知
994
- async function noticePushoo (comment) {
995
- if (!config.PUSHOO_CHANNEL || !config.PUSHOO_TOKEN) {
996
- console.log('没有配置 pushoo,放弃即时消息通知')
997
- return
998
- }
999
- if (config.BLOGGER_EMAIL === comment.mail) return
1000
- const pushContent = getIMPushContent(comment)
1001
- const sendResult = await pushoo(config.PUSHOO_CHANNEL, {
1002
- token: config.PUSHOO_TOKEN,
1003
- title: pushContent.subject,
1004
- content: pushContent.content,
1005
- options: {
1006
- bark: {
1007
- url: pushContent.url
1008
- }
1009
- }
1010
- })
1011
- console.log('即时消息通知结果:', sendResult)
1012
- }
1013
-
1014
- // 即时消息推送内容获取
1015
- function getIMPushContent (comment) {
1016
- const SITE_NAME = config.SITE_NAME
1017
- const NICK = comment.nick
1018
- const MAIL = comment.mail
1019
- const IP = comment.ip
1020
- const COMMENT = $(comment.comment).text()
1021
- const SITE_URL = config.SITE_URL
1022
- const POST_URL = appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
1023
- const subject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}有新评论了`
1024
- const content = `评论人:${NICK} ([${MAIL}](mailto:${MAIL}))
1025
-
1026
- 评论人IP:${IP}
1027
-
1028
- 评论内容:${COMMENT}
1029
-
1030
- 原文链接:[${POST_URL}](${POST_URL})`
1031
- return {
1032
- subject,
1033
- content,
1034
- url: POST_URL
1035
- }
1036
- }
1037
-
1038
- // 回复通知
1039
- async function noticeReply (currentComment) {
1040
- if (!currentComment.pid) return
1041
- if (!transporter) if (!await initMailer()) return
1042
- let parentComment = await db
1043
- .collection('comment')
1044
- .where({ _id: currentComment.pid })
1045
- .get()
1046
- parentComment = parentComment.data[0]
1047
- // 回复给博主,因为会发博主通知邮件,所以不再重复通知
1048
- if (config.BLOGGER_EMAIL === parentComment.mail) return
1049
- // 回复自己的评论,不邮件通知
1050
- if (currentComment.mail === parentComment.mail) return
1051
- const PARENT_NICK = parentComment.nick
1052
- const IMG = getAvatar(currentComment)
1053
- const PARENT_IMG = getAvatar(parentComment)
1054
- const SITE_NAME = config.SITE_NAME
1055
- const NICK = currentComment.nick
1056
- const COMMENT = currentComment.comment
1057
- const PARENT_COMMENT = parentComment.comment
1058
- const POST_URL = appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id)
1059
- const SITE_URL = config.SITE_URL
1060
- const emailSubject = config.MAIL_SUBJECT || `${PARENT_NICK},您在『${SITE_NAME}』上的评论收到了回复`
1061
- let emailContent
1062
- if (config.MAIL_TEMPLATE) {
1063
- emailContent = config.MAIL_TEMPLATE
1064
- .replace(/\${IMG}/g, IMG)
1065
- .replace(/\${PARENT_IMG}/g, PARENT_IMG)
1066
- .replace(/\${SITE_URL}/g, SITE_URL)
1067
- .replace(/\${SITE_NAME}/g, SITE_NAME)
1068
- .replace(/\${PARENT_NICK}/g, PARENT_NICK)
1069
- .replace(/\${PARENT_COMMENT}/g, PARENT_COMMENT)
1070
- .replace(/\${NICK}/g, NICK)
1071
- .replace(/\${COMMENT}/g, COMMENT)
1072
- .replace(/\${POST_URL}/g, POST_URL)
1073
- } else {
1074
- emailContent = `
1075
- <div style="border-top:2px solid #12ADDB;box-shadow:0 1px 3px #AAAAAA;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;">
1076
- <h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">
1077
- 您在<a style="text-decoration:none;color: #12ADDB;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的评论有了新的回复
1078
- </h2>
1079
- ${PARENT_NICK} 同学,您曾发表评论:
1080
- <div style="padding:0 12px 0 12px;margin-top:18px">
1081
- <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${PARENT_COMMENT}</div>
1082
- <p><strong>${NICK}</strong>回复说:</p>
1083
- <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div>
1084
- <p>
1085
- 您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a>,
1086
- 欢迎再次光临<a style="text-decoration:none; color:#12addb" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>。<br>
1087
- </p>
1088
- </div>
1089
- </div>`
1090
- }
1091
- let sendResult
1092
- try {
1093
- sendResult = await transporter.sendMail({
1094
- from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`,
1095
- to: parentComment.mail,
1096
- subject: emailSubject,
1097
- html: emailContent
1098
- })
1099
- } catch (e) {
1100
- sendResult = e
1101
- }
1102
- console.log('回复通知结果:', sendResult)
1103
- return sendResult
1104
- }
1105
-
1106
- function appendHashToUrl (url, hash) {
1107
- if (url.indexOf('#') === -1) {
1108
- return `${url}#${hash}`
1109
- } else {
1110
- return `${url.substring(0, url.indexOf('#'))}#${hash}`
1111
- }
1112
- }
1113
-
1114
585
  // 将评论转为数据库存储格式
1115
586
  async function parse (comment) {
1116
587
  const timestamp = Date.now()
@@ -1131,7 +602,7 @@ async function parse (comment) {
1131
602
  comment: DOMPurify.sanitize(comment.comment, { FORBID_TAGS: ['style'], FORBID_ATTR: ['style'] }),
1132
603
  pid: comment.pid ? comment.pid : comment.rid,
1133
604
  rid: comment.rid,
1134
- isSpam: isAdminUser ? false : preCheckSpam(comment),
605
+ isSpam: isAdminUser ? false : preCheckSpam(comment, config),
1135
606
  created: timestamp,
1136
607
  updated: timestamp
1137
608
  }
@@ -1178,86 +649,16 @@ async function limitFilter () {
1178
649
  }
1179
650
  }
1180
651
 
1181
- // 预垃圾评论检测
1182
- function preCheckSpam ({ comment, nick }) {
1183
- // 长度限制
1184
- let limitLength = parseInt(config.LIMIT_LENGTH)
1185
- if (Number.isNaN(limitLength)) limitLength = 500
1186
- if (limitLength && comment.length > limitLength) {
1187
- throw new Error('评论内容过长')
1188
- }
1189
- if (config.AKISMET_KEY === 'MANUAL_REVIEW') {
1190
- // 人工审核
1191
- console.log('已使用人工审核模式,评论审核后才会发表~')
1192
- return true
1193
- } else if (config.FORBIDDEN_WORDS) {
1194
- // 违禁词检测
1195
- for (const forbiddenWord of config.FORBIDDEN_WORDS.split(',')) {
1196
- if (comment.indexOf(forbiddenWord.trim()) !== -1 || nick.indexOf(forbiddenWord.trim()) !== -1) {
1197
- console.log('包含违禁词,直接标记为垃圾评论~')
1198
- return true
1199
- }
1200
- }
1201
- }
1202
- return false
1203
- }
1204
-
1205
- // 后垃圾评论检测
1206
- async function postCheckSpam (comment) {
1207
- try {
1208
- let isSpam
1209
- if (comment.isSpam) {
1210
- // 预检测没过的,就不再检测了
1211
- isSpam = true
1212
- } else if (config.QCLOUD_SECRET_ID && config.QCLOUD_SECRET_KEY) {
1213
- // 腾讯云内容安全
1214
- const client = new tencentcloud.tms.v20200713.Client({
1215
- credential: { secretId: config.QCLOUD_SECRET_ID, secretKey: config.QCLOUD_SECRET_KEY },
1216
- region: 'ap-shanghai',
1217
- profile: { httpProfile: { endpoint: 'tms.tencentcloudapi.com' } }
1218
- })
1219
- const checkResult = await client.TextModeration({
1220
- Content: CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(comment.comment)),
1221
- Device: { IP: comment.ip },
1222
- User: { Nickname: comment.nick }
1223
- })
1224
- console.log('腾讯云返回结果:', checkResult)
1225
- isSpam = checkResult.EvilFlag !== 0
1226
- } else if (config.AKISMET_KEY) {
1227
- // Akismet
1228
- const akismetClient = new AkismetClient({
1229
- key: config.AKISMET_KEY,
1230
- blog: config.SITE_URL
1231
- })
1232
- const isValid = await akismetClient.verifyKey()
1233
- if (!isValid) {
1234
- console.log('Akismet key 不可用:', config.AKISMET_KEY)
1235
- return
1236
- }
1237
- isSpam = await akismetClient.checkSpam({
1238
- user_ip: comment.ip,
1239
- user_agent: comment.ua,
1240
- permalink: comment.href,
1241
- comment_type: comment.rid ? 'reply' : 'comment',
1242
- comment_author: comment.nick,
1243
- comment_author_email: comment.mail,
1244
- comment_author_url: comment.link,
1245
- comment_content: comment.comment
652
+ async function saveSpamCheckResult (comment, isSpam) {
653
+ comment.isSpam = isSpam
654
+ if (isSpam) {
655
+ await db
656
+ .collection('comment')
657
+ .doc(comment.id)
658
+ .update({
659
+ isSpam,
660
+ updated: Date.now()
1246
661
  })
1247
- }
1248
- console.log('垃圾评论检测结果:', isSpam)
1249
- comment.isSpam = isSpam
1250
- if (isSpam) {
1251
- await db
1252
- .collection('comment')
1253
- .doc(comment.id)
1254
- .update({
1255
- isSpam,
1256
- updated: Date.now()
1257
- })
1258
- }
1259
- } catch (err) {
1260
- console.error('垃圾评论检测异常:', err)
1261
662
  }
1262
663
  }
1263
664
 
@@ -1353,14 +754,6 @@ async function getCommentsCount (event) {
1353
754
  return res
1354
755
  }
1355
756
 
1356
- function getUrlsQuery (urls) {
1357
- const query = []
1358
- for (const url of urls) {
1359
- if (url) query.push(...getUrlQuery(url))
1360
- }
1361
- return query
1362
- }
1363
-
1364
757
  /**
1365
758
  * 获取最新评论 API
1366
759
  * @param {Boolean} event.includeReply 评论数是否包括回复,默认:false
@@ -1384,7 +777,7 @@ async function getRecentComments (event) {
1384
777
  url: comment.url,
1385
778
  href: comment.href,
1386
779
  nick: comment.nick,
1387
- avatar: getAvatar(comment),
780
+ avatar: getAvatar(comment, config),
1388
781
  mailMd5: comment.mailMd5 || md5(comment.mail),
1389
782
  link: comment.link,
1390
783
  comment: comment.comment,
@@ -1399,183 +792,6 @@ async function getRecentComments (event) {
1399
792
  return res
1400
793
  }
1401
794
 
1402
- async function emailTest (event) {
1403
- const res = {}
1404
- const isAdminUser = await isAdmin()
1405
- if (isAdminUser) {
1406
- try {
1407
- // 邮件测试前清除 transporter,保证读取的是最新的配置
1408
- transporter = null
1409
- await initMailer({ throwErr: true })
1410
- const sendResult = await transporter.sendMail({
1411
- from: config.SENDER_EMAIL,
1412
- to: event.mail || config.BLOGGER_EMAIL || config.SENDER_EMAIL,
1413
- subject: 'Twikoo 邮件通知测试邮件',
1414
- html: '如果您收到这封邮件,说明 Twikoo 邮件功能配置正确'
1415
- })
1416
- res.result = sendResult
1417
- } catch (e) {
1418
- res.message = e.message
1419
- }
1420
- } else {
1421
- res.code = RES_CODE.NEED_LOGIN
1422
- res.message = '请先登录'
1423
- }
1424
- return res
1425
- }
1426
-
1427
- async function uploadImage (event) {
1428
- const { photo, fileName } = event
1429
- const res = {}
1430
- try {
1431
- if (!config.IMAGE_CDN || !config.IMAGE_CDN_TOKEN) {
1432
- throw new Error('未配置图片上传服务')
1433
- }
1434
- // tip: qcloud 图床走前端上传,其他图床走后端上传
1435
- if (config.IMAGE_CDN === '7bu') {
1436
- await uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: 'https://7bu.top' })
1437
- } else if (config.IMAGE_CDN === 'smms') {
1438
- await uploadImageToSmms({ photo, fileName, config, res })
1439
- } else if (isUrl(config.IMAGE_CDN)) {
1440
- await uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN })
1441
- }
1442
- } catch (e) {
1443
- console.error(e)
1444
- res.code = RES_CODE.UPLOAD_FAILED
1445
- res.err = e.message
1446
- }
1447
- return res
1448
- }
1449
-
1450
- async function uploadImageToSmms ({ photo, fileName, config, res }) {
1451
- // SM.MS 图床 https://sm.ms
1452
- const formData = new FormData()
1453
- formData.append('smfile', base64UrlToReadStream(photo, fileName))
1454
- const uploadResult = await axios.post('https://sm.ms/api/v2/upload', formData, {
1455
- headers: {
1456
- ...formData.getHeaders(),
1457
- Authorization: config.IMAGE_CDN_TOKEN
1458
- }
1459
- })
1460
- if (uploadResult.data.success) {
1461
- res.data = uploadResult.data.data
1462
- } else {
1463
- throw new Error(uploadResult.data.message)
1464
- }
1465
- }
1466
-
1467
- async function uploadImageToLskyPro ({ photo, fileName, config, res, imageCdn }) {
1468
- // 自定义兰空图床(v2)URL
1469
- const formData = new FormData()
1470
- formData.append('file', base64UrlToReadStream(photo, fileName))
1471
- const url = `${imageCdn}/api/v1/upload`
1472
- let token = config.IMAGE_CDN_TOKEN
1473
- if (!token.startsWith('Bearer')) {
1474
- token = `Bearer ${token}`
1475
- }
1476
- const uploadResult = await axios.post(url, formData, {
1477
- headers: {
1478
- ...formData.getHeaders(),
1479
- Authorization: token
1480
- }
1481
- })
1482
- if (uploadResult.data.status) {
1483
- res.data = uploadResult.data.data
1484
- res.data.url = res.data.links.url
1485
- } else {
1486
- throw new Error(uploadResult.data.message)
1487
- }
1488
- }
1489
-
1490
- function base64UrlToReadStream (base64Url, fileName) {
1491
- const base64 = base64Url.split(';base64,').pop()
1492
- const path = `/tmp/${fileName}`
1493
- fs.writeFileSync(path, base64, { encoding: 'base64' })
1494
- return fs.createReadStream(path)
1495
- }
1496
-
1497
- function isUrl (s) {
1498
- return /^http(s)?:\/\//.test(s)
1499
- }
1500
-
1501
- function getAvatar (comment) {
1502
- if (comment.avatar) {
1503
- return comment.avatar
1504
- } else {
1505
- const gravatarCdn = config.GRAVATAR_CDN || 'cravatar.cn'
1506
- const defaultGravatar = config.DEFAULT_GRAVATAR || 'identicon'
1507
- const mailMd5 = comment.mailMd5 || md5(comment.mail)
1508
- return `https://${gravatarCdn}/avatar/${mailMd5}?d=${defaultGravatar}`
1509
- }
1510
- }
1511
-
1512
- function isQQ (mail) {
1513
- return /^[1-9][0-9]{4,10}$/.test(mail) ||
1514
- /^[1-9][0-9]{4,10}@qq.com$/i.test(mail)
1515
- }
1516
-
1517
- function addQQMailSuffix (mail) {
1518
- if (/^[1-9][0-9]{4,10}$/.test(mail)) return `${mail}@qq.com`
1519
- else return mail
1520
- }
1521
-
1522
- async function getQQAvatar (qq) {
1523
- try {
1524
- const qqNum = qq.replace(/@qq.com/ig, '')
1525
- const result = await axios.get(`https://ptlogin2.qq.com/getface?imgtype=4&uin=${qqNum}`)
1526
- if (result && result.data) {
1527
- const start = result.data.indexOf('http')
1528
- const end = result.data.indexOf('"', start)
1529
- if (start === -1 || end === -1) return null
1530
- return result.data.substring(start, end)
1531
- }
1532
- } catch (e) {
1533
- console.error('获取 QQ 头像失败:', e)
1534
- }
1535
- }
1536
-
1537
- function getConfig () {
1538
- return {
1539
- code: RES_CODE.SUCCESS,
1540
- config: {
1541
- VERSION,
1542
- SITE_NAME: config.SITE_NAME,
1543
- SITE_URL: config.SITE_URL,
1544
- MASTER_TAG: config.MASTER_TAG,
1545
- COMMENT_BG_IMG: config.COMMENT_BG_IMG,
1546
- GRAVATAR_CDN: config.GRAVATAR_CDN,
1547
- DEFAULT_GRAVATAR: config.DEFAULT_GRAVATAR,
1548
- SHOW_IMAGE: config.SHOW_IMAGE || 'true',
1549
- IMAGE_CDN: config.IMAGE_CDN,
1550
- IMAGE_CDN_TOKEN: config.IMAGE_CDN_TOKEN,
1551
- SHOW_EMOTION: config.SHOW_EMOTION || 'true',
1552
- EMOTION_CDN: config.EMOTION_CDN,
1553
- COMMENT_PLACEHOLDER: config.COMMENT_PLACEHOLDER,
1554
- REQUIRED_FIELDS: config.REQUIRED_FIELDS,
1555
- HIDE_ADMIN_CRYPT: config.HIDE_ADMIN_CRYPT,
1556
- HIGHLIGHT: config.HIGHLIGHT || 'true',
1557
- HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
1558
- LIMIT_LENGTH: config.LIMIT_LENGTH
1559
- }
1560
- }
1561
- }
1562
-
1563
- async function getConfigForAdmin () {
1564
- const isAdminUser = await isAdmin()
1565
- if (isAdminUser) {
1566
- delete config.CREDENTIALS
1567
- return {
1568
- code: RES_CODE.SUCCESS,
1569
- config
1570
- }
1571
- } else {
1572
- return {
1573
- code: RES_CODE.NEED_LOGIN,
1574
- message: '请先登录'
1575
- }
1576
- }
1577
- }
1578
-
1579
795
  // 修改配置
1580
796
  async function setConfig (event) {
1581
797
  const isAdminUser = await isAdmin()
@@ -1660,29 +876,6 @@ async function isAdmin () {
1660
876
  return ADMIN_USER_ID === userInfo.userInfo.customUserId
1661
877
  }
1662
878
 
1663
- /**
1664
- * 获取 IP 属地
1665
- * @param detail true 返回省市运营商,false 只返回省
1666
- * @returns {String}
1667
- */
1668
- function getIpRegion ({ ip, detail = false }) {
1669
- if (!ip) return ''
1670
- try {
1671
- const { region } = ipRegionSearcher.binarySearchSync(ip)
1672
- const [country,, province, city, isp] = region.split('|')
1673
- // 有省显示省,没有省显示国家
1674
- const area = province.trim() ? province : country
1675
- if (detail) {
1676
- return area === city ? [city, isp].join(' ') : [area, city, isp].join(' ')
1677
- } else {
1678
- return area
1679
- }
1680
- } catch (e) {
1681
- console.error('IP 属地查询失败:', e)
1682
- return ''
1683
- }
1684
- }
1685
-
1686
879
  // 判断是否为递归调用(即云函数调用自身)
1687
880
  function isRecursion (context) {
1688
881
  const envObj = tcb.getCloudbaseContext(context)
@@ -1702,12 +895,3 @@ async function createCollections () {
1702
895
  }
1703
896
  return res
1704
897
  }
1705
-
1706
- // 请求参数校验
1707
- function validate (event = {}, requiredParams = []) {
1708
- for (const requiredParam of requiredParams) {
1709
- if (!event[requiredParam]) {
1710
- throw new Error(`参数"${requiredParam}"不合法`)
1711
- }
1712
- }
1713
- }