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