twikoo-func 1.6.45 → 1.7.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
@@ -26,9 +26,11 @@ const {
26
26
  isQQ,
27
27
  addQQMailSuffix,
28
28
  getQQAvatar,
29
+ getQQNick,
29
30
  getPasswordStatus,
30
31
  preCheckSpam,
31
32
  checkTurnstileCaptcha,
33
+ checkGeeTestCaptcha,
32
34
  getConfig,
33
35
  getConfigForAdmin,
34
36
  validate
@@ -137,6 +139,9 @@ exports.main = async (event, context) => {
137
139
  case 'UPLOAD_IMAGE': // >= 1.5.0
138
140
  res = await uploadImage(event, config)
139
141
  break
142
+ case 'GET_QQ_NICK': // >= 1.7.0
143
+ res = await qqNickGet(event)
144
+ break
140
145
  case 'COMMENT_EXPORT_FOR_ADMIN': // >= 1.6.13
141
146
  res = await commentExportForAdmin(event)
142
147
  break
@@ -233,6 +238,7 @@ async function commentGet (event) {
233
238
  const uid = await auth.getEndUserInfo().userInfo.uid
234
239
  const isAdminUser = await isAdmin()
235
240
  const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8
241
+ const sort = event.sort || 'newest'
236
242
  let more = false
237
243
  let condition
238
244
  let query
@@ -254,10 +260,21 @@ async function commentGet (event) {
254
260
  // 不包含置顶
255
261
  condition.top = _.neq(true)
256
262
  query = getCommentQuery({ condition, uid, isAdminUser })
263
+
264
+ let orderField = 'created'
265
+ let orderDirection = 'desc'
266
+ if (sort === 'oldest') {
267
+ orderField = 'created'
268
+ orderDirection = 'asc'
269
+ } else if (sort === 'popular') {
270
+ orderField = 'ups'
271
+ orderDirection = 'desc'
272
+ }
273
+
257
274
  const main = await db
258
275
  .collection('comment')
259
276
  .where(query)
260
- .orderBy('created', 'desc')
277
+ .orderBy(orderField, orderDirection)
261
278
  // 流式分页,通过多读 1 条的方式,确认是否还有更多评论
262
279
  .limit(limit + 1)
263
280
  .get()
@@ -514,7 +531,7 @@ async function bulkSaveComments (comments) {
514
531
  return batchRes.ids
515
532
  }
516
533
 
517
- // 点赞 / 取消点赞
534
+ // 点赞 / 反对 / 取消点赞
518
535
  async function commentLike (event) {
519
536
  const res = {}
520
537
  let uid
@@ -525,25 +542,41 @@ async function commentLike (event) {
525
542
  res.message = e.message
526
543
  return res
527
544
  }
528
- res.updated = await like(event.id, uid)
545
+ const type = event.type || 'up'
546
+ res.updated = await like(event.id, uid, type)
529
547
  return res
530
548
  }
531
549
 
532
- // 点赞 / 取消点赞
533
- async function like (id, uid) {
550
+ // 点赞 / 反对 / 取消
551
+ async function like (id, uid, type) {
534
552
  const record = db
535
553
  .collection('comment')
536
554
  .where({ _id: id })
537
555
  const comment = await record.get()
538
- let likes = comment.data[0] && comment.data[0].like ? comment.data[0].like : []
539
- if (likes.findIndex((item) => item === uid) === -1) {
540
- //
541
- likes.push(uid)
542
- } else {
543
- // 取消赞
544
- likes = likes.filter((item) => item !== uid)
556
+ const commentData = comment.data[0] || {}
557
+ const ups = commentData.ups || []
558
+ const downs = commentData.downs || []
559
+
560
+ let newUps = [...ups]
561
+ let newDowns = [...downs]
562
+
563
+ if (type === 'up') {
564
+ if (ups.includes(uid)) {
565
+ newUps = ups.filter((item) => item !== uid)
566
+ } else {
567
+ newUps.push(uid)
568
+ newDowns = downs.filter((item) => item !== uid)
569
+ }
570
+ } else if (type === 'down') {
571
+ if (downs.includes(uid)) {
572
+ newDowns = downs.filter((item) => item !== uid)
573
+ } else {
574
+ newDowns.push(uid)
575
+ newUps = ups.filter((item) => item !== uid)
576
+ }
545
577
  }
546
- const result = await record.update({ like: likes })
578
+
579
+ const result = await record.update({ ups: newUps, downs: newDowns })
547
580
  return result.updated
548
581
  }
549
582
 
@@ -685,7 +718,27 @@ async function limitFilter () {
685
718
  }
686
719
 
687
720
  async function checkCaptcha (comment) {
688
- if (config.TURNSTILE_SITE_KEY && config.TURNSTILE_SECRET_KEY) {
721
+ const provider = config.CAPTCHA_PROVIDER
722
+ if ((!provider || provider === 'Turnstile') && config.TURNSTILE_SITE_KEY && config.TURNSTILE_SECRET_KEY) {
723
+ await checkTurnstileCaptcha({
724
+ ip: auth.getClientIP(),
725
+ turnstileToken: comment.turnstileToken,
726
+ turnstileTokenSecretKey: config.TURNSTILE_SECRET_KEY
727
+ })
728
+ }
729
+ if ((!provider || provider === 'Geetest') && config.GEETEST_CAPTCHA_ID && config.GEETEST_CAPTCHA_KEY) {
730
+ await checkGeeTestCaptcha({
731
+ geeTestCaptchaId: config.GEETEST_CAPTCHA_ID,
732
+ geeTestCaptchaKey: config.GEETEST_CAPTCHA_KEY,
733
+ geeTestLotNumber: comment.geeTestLotNumber,
734
+ geeTestCaptchaOutput: comment.geeTestCaptchaOutput,
735
+ geeTestPassToken: comment.geeTestPassToken,
736
+ geeTestGenTime: comment.geeTestGenTime
737
+ })
738
+ } else if (config.TURNSTILE_SITE_KEY) {
739
+ if (!config.TURNSTILE_SECRET_KEY) {
740
+ throw new Error('Turnstile 验证码配置不完整,缺少 TURNSTILE_SECRET_KEY')
741
+ }
689
742
  await checkTurnstileCaptcha({
690
743
  ip: auth.getClientIP(),
691
744
  turnstileToken: comment.turnstileToken,
@@ -840,6 +893,21 @@ async function getRecentComments (event) {
840
893
  return res
841
894
  }
842
895
 
896
+ // 获取 QQ 昵称
897
+ async function qqNickGet (event) {
898
+ const res = {}
899
+ try {
900
+ validate(event, ['qq'])
901
+ const nick = await getQQNick(event.qq, config.QQ_API_KEY)
902
+ res.code = RES_CODE.SUCCESS
903
+ res.nick = nick
904
+ } catch (e) {
905
+ res.code = RES_CODE.FAIL
906
+ res.message = e.message
907
+ }
908
+ return res
909
+ }
910
+
843
911
  // 修改配置
844
912
  async function setConfig (event) {
845
913
  const isAdminUser = await isAdmin()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twikoo-func",
3
- "version": "1.6.45",
3
+ "version": "1.7.1",
4
4
  "description": "A simple comment system.",
5
5
  "author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
6
6
  "license": "MIT",
@@ -13,7 +13,8 @@ module.exports = {
13
13
  NEED_LOGIN: 1024,
14
14
  FORBIDDEN: 1403,
15
15
  AKISMET_ERROR: 1030,
16
- UPLOAD_FAILED: 1040
16
+ UPLOAD_FAILED: 1040,
17
+ NSFW_REJECTED: 1041
17
18
  },
18
19
  MAX_REQUEST_TIMES: parseInt(process.env.TWIKOO_THROTTLE) || 250
19
20
  }
package/utils/image.js CHANGED
@@ -16,6 +16,14 @@ const fn = {
16
16
  if (!config.IMAGE_CDN || !config.IMAGE_CDN_TOKEN) {
17
17
  throw new Error('未配置图片上传服务')
18
18
  }
19
+ if (config.NSFW_API_URL) {
20
+ const nsfwResult = await fn.checkNsfw({ photo, config })
21
+ if (nsfwResult.rejected) {
22
+ res.code = RES_CODE.NSFW_REJECTED
23
+ res.err = nsfwResult.message
24
+ return res
25
+ }
26
+ }
19
27
  // tip: qcloud 图床走前端上传,其他图床走后端上传
20
28
  if (config.IMAGE_CDN === '7bu') {
21
29
  await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: 'https://7bu.top' })
@@ -41,6 +49,33 @@ const fn = {
41
49
  }
42
50
  return res
43
51
  },
52
+ async checkNsfw ({ photo, config }) {
53
+ const result = { rejected: false, message: '' }
54
+ try {
55
+ const threshold = parseFloat(config.NSFW_THRESHOLD) || 0.5
56
+ const apiUrl = config.NSFW_API_URL.replace(/\/$/, '')
57
+ const formData = new FormData()
58
+ formData.append('image', fn.base64UrlToReadStream(photo, 'nsfw_check.jpg'))
59
+ const response = await axios.post(`${apiUrl}/classify`, formData, {
60
+ headers: {
61
+ ...formData.getHeaders()
62
+ },
63
+ timeout: 30000
64
+ })
65
+ const scores = response.data
66
+ if (scores && typeof scores === 'object') {
67
+ const nsfwScore = (scores.porn || 0) + (scores.hentai || 0) + (scores.sexy || 0)
68
+ logger.info('NSFW检测分数:', nsfwScore, '阈值:', threshold)
69
+ if (nsfwScore > threshold) {
70
+ result.rejected = true
71
+ result.message = `图片包含不当内容,检测分数 ${nsfwScore.toFixed(3)} 超过阈值 ${threshold}`
72
+ }
73
+ }
74
+ } catch (e) {
75
+ logger.error('NSFW检测失败:', e.message)
76
+ }
77
+ return result
78
+ },
44
79
  async uploadImageToSee ({ photo, fileName, config, res, imageCdn }) {
45
80
  // S.EE 图床 https://s.ee (原 SM.MS)
46
81
  const formData = new FormData()
package/utils/index.js CHANGED
@@ -75,6 +75,8 @@ const fn = {
75
75
  }
76
76
  }
77
77
  const showRegion = !!config.SHOW_REGION && config.SHOW_REGION !== 'false'
78
+ const ups = comment.ups || []
79
+ const downs = comment.downs || []
78
80
  return {
79
81
  id: comment._id.toString(),
80
82
  nick: comment.nick,
@@ -87,7 +89,10 @@ const fn = {
87
89
  ipRegion: showRegion ? fn.getIpRegion({ ip: comment.ip }) : '',
88
90
  master: comment.master,
89
91
  like: comment.like ? comment.like.length : 0,
90
- liked: comment.like ? comment.like.findIndex((item) => item === uid) > -1 : false,
92
+ ups: ups.length,
93
+ downs: downs.length,
94
+ liked: ups.includes(uid),
95
+ disliked: downs.includes(uid),
91
96
  replies: replies,
92
97
  rid: comment.rid,
93
98
  pid: comment.pid,
@@ -244,6 +249,23 @@ const fn = {
244
249
  logger.warn('获取 QQ 头像失败:', e)
245
250
  }
246
251
  },
252
+ async getQQNick (qq, qqApiKey) {
253
+ try {
254
+ const qqNum = qq.replace(/@qq.com/ig, '')
255
+ const headers = {}
256
+ if (qqApiKey) {
257
+ headers.Authorization = `Bearer ${qqApiKey}`
258
+ }
259
+ const result = await axios.get(`https://v1.nsuuu.com/api/qqname?qq=${qqNum}`, { headers })
260
+ if (result.data?.code === 200 && result.data?.data?.nick) {
261
+ return result.data.data.nick
262
+ }
263
+ return null
264
+ } catch (e) {
265
+ logger.warn('获取 QQ 昵称失败:', e)
266
+ return null
267
+ }
268
+ },
247
269
  // 判断是否存在管理员密码
248
270
  async getPasswordStatus (config, version) {
249
271
  return {
@@ -304,33 +326,75 @@ const fn = {
304
326
  throw new Error('验证码检测失败: ' + e.message)
305
327
  }
306
328
  },
329
+ async checkGeeTestCaptcha ({ geeTestCaptchaId, geeTestCaptchaKey, geeTestLotNumber, geeTestCaptchaOutput, geeTestPassToken, geeTestGenTime }) {
330
+ try {
331
+ logger.log('极验验证参数:', { geeTestCaptchaId, geeTestCaptchaKey: geeTestCaptchaKey ? '***' : undefined, geeTestLotNumber })
332
+ const crypto = require('crypto')
333
+ const signToken = crypto
334
+ .createHmac('sha256', geeTestCaptchaKey)
335
+ .update(geeTestLotNumber)
336
+ .digest('hex')
337
+ const params = new URLSearchParams()
338
+ params.append('lot_number', geeTestLotNumber)
339
+ params.append('captcha_output', geeTestCaptchaOutput)
340
+ params.append('pass_token', geeTestPassToken)
341
+ params.append('gen_time', geeTestGenTime)
342
+ params.append('sign_token', signToken)
343
+ logger.log('极验请求参数:', params.toString())
344
+ const url = `https://gcaptcha4.geetest.com/validate?captcha_id=${geeTestCaptchaId}`
345
+ const { data } = await axios.post(url, params.toString(), {
346
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
347
+ })
348
+ logger.log('极验验证码检测结果', JSON.stringify(data))
349
+ if (data.result !== 'success') {
350
+ logger.error('极验验证失败详情:', data)
351
+ throw new Error(data.reason || data.msg || '验证码错误')
352
+ }
353
+ } catch (e) {
354
+ throw new Error('极验验证码检测失败: ' + e.message)
355
+ }
356
+ },
307
357
  async getConfig ({ config, VERSION, isAdmin }) {
358
+ // 构建对外配置,避免在启用某一验证码供应商时泄露另一个供应商的 key
359
+ const baseConfig = {
360
+ VERSION,
361
+ IS_ADMIN: isAdmin,
362
+ SITE_NAME: config.SITE_NAME,
363
+ SITE_URL: config.SITE_URL,
364
+ MASTER_TAG: config.MASTER_TAG,
365
+ COMMENT_BG_IMG: config.COMMENT_BG_IMG,
366
+ GRAVATAR_CDN: config.GRAVATAR_CDN,
367
+ DEFAULT_GRAVATAR: config.DEFAULT_GRAVATAR,
368
+ SHOW_IMAGE: config.SHOW_IMAGE || 'true',
369
+ IMAGE_CDN: config.IMAGE_CDN,
370
+ LIGHTBOX: config.LIGHTBOX || 'false',
371
+ SHOW_EMOTION: config.SHOW_EMOTION || 'true',
372
+ EMOTION_CDN: config.EMOTION_CDN,
373
+ COMMENT_PLACEHOLDER: config.COMMENT_PLACEHOLDER,
374
+ DISPLAYED_FIELDS: config.DISPLAYED_FIELDS,
375
+ REQUIRED_FIELDS: config.REQUIRED_FIELDS,
376
+ HIDE_ADMIN_CRYPT: config.HIDE_ADMIN_CRYPT,
377
+ HIGHLIGHT: config.HIGHLIGHT || 'true',
378
+ HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
379
+ HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
380
+ LIMIT_LENGTH: config.LIMIT_LENGTH,
381
+ CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER,
382
+ QQ_API_KEY: config.QQ_API_KEY
383
+ }
384
+
385
+ // 仅在明确指定使用 Turnstile 时下发 Turnstile 的 site key
386
+ if (config.CAPTCHA_PROVIDER === 'Turnstile') {
387
+ baseConfig.TURNSTILE_SITE_KEY = config.TURNSTILE_SITE_KEY
388
+ }
389
+
390
+ // 仅在明确指定使用 Geetest 时下发 Geetest 的 id
391
+ if (config.CAPTCHA_PROVIDER === 'Geetest') {
392
+ baseConfig.GEETEST_CAPTCHA_ID = config.GEETEST_CAPTCHA_ID
393
+ }
394
+
308
395
  return {
309
396
  code: RES_CODE.SUCCESS,
310
- config: {
311
- VERSION,
312
- IS_ADMIN: isAdmin,
313
- SITE_NAME: config.SITE_NAME,
314
- SITE_URL: config.SITE_URL,
315
- MASTER_TAG: config.MASTER_TAG,
316
- COMMENT_BG_IMG: config.COMMENT_BG_IMG,
317
- GRAVATAR_CDN: config.GRAVATAR_CDN,
318
- DEFAULT_GRAVATAR: config.DEFAULT_GRAVATAR,
319
- SHOW_IMAGE: config.SHOW_IMAGE || 'true',
320
- IMAGE_CDN: config.IMAGE_CDN,
321
- LIGHTBOX: config.LIGHTBOX || 'false',
322
- SHOW_EMOTION: config.SHOW_EMOTION || 'true',
323
- EMOTION_CDN: config.EMOTION_CDN,
324
- COMMENT_PLACEHOLDER: config.COMMENT_PLACEHOLDER,
325
- DISPLAYED_FIELDS: config.DISPLAYED_FIELDS,
326
- REQUIRED_FIELDS: config.REQUIRED_FIELDS,
327
- HIDE_ADMIN_CRYPT: config.HIDE_ADMIN_CRYPT,
328
- HIGHLIGHT: config.HIGHLIGHT || 'true',
329
- HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
330
- HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
331
- LIMIT_LENGTH: config.LIMIT_LENGTH,
332
- TURNSTILE_SITE_KEY: config.TURNSTILE_SITE_KEY
333
- }
397
+ config: baseConfig
334
398
  }
335
399
  },
336
400
  async getConfigForAdmin ({ config, isAdmin }) {