twikoo-func 1.6.44 → 1.7.0

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
@@ -29,6 +29,7 @@ const {
29
29
  getPasswordStatus,
30
30
  preCheckSpam,
31
31
  checkTurnstileCaptcha,
32
+ checkGeeTestCaptcha,
32
33
  getConfig,
33
34
  getConfigForAdmin,
34
35
  validate
@@ -233,6 +234,7 @@ async function commentGet (event) {
233
234
  const uid = await auth.getEndUserInfo().userInfo.uid
234
235
  const isAdminUser = await isAdmin()
235
236
  const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8
237
+ const sort = event.sort || 'newest'
236
238
  let more = false
237
239
  let condition
238
240
  let query
@@ -254,10 +256,21 @@ async function commentGet (event) {
254
256
  // 不包含置顶
255
257
  condition.top = _.neq(true)
256
258
  query = getCommentQuery({ condition, uid, isAdminUser })
259
+
260
+ let orderField = 'created'
261
+ let orderDirection = 'desc'
262
+ if (sort === 'oldest') {
263
+ orderField = 'created'
264
+ orderDirection = 'asc'
265
+ } else if (sort === 'popular') {
266
+ orderField = 'ups'
267
+ orderDirection = 'desc'
268
+ }
269
+
257
270
  const main = await db
258
271
  .collection('comment')
259
272
  .where(query)
260
- .orderBy('created', 'desc')
273
+ .orderBy(orderField, orderDirection)
261
274
  // 流式分页,通过多读 1 条的方式,确认是否还有更多评论
262
275
  .limit(limit + 1)
263
276
  .get()
@@ -514,7 +527,7 @@ async function bulkSaveComments (comments) {
514
527
  return batchRes.ids
515
528
  }
516
529
 
517
- // 点赞 / 取消点赞
530
+ // 点赞 / 反对 / 取消点赞
518
531
  async function commentLike (event) {
519
532
  const res = {}
520
533
  let uid
@@ -525,25 +538,41 @@ async function commentLike (event) {
525
538
  res.message = e.message
526
539
  return res
527
540
  }
528
- res.updated = await like(event.id, uid)
541
+ const type = event.type || 'up'
542
+ res.updated = await like(event.id, uid, type)
529
543
  return res
530
544
  }
531
545
 
532
- // 点赞 / 取消点赞
533
- async function like (id, uid) {
546
+ // 点赞 / 反对 / 取消
547
+ async function like (id, uid, type) {
534
548
  const record = db
535
549
  .collection('comment')
536
550
  .where({ _id: id })
537
551
  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)
552
+ const commentData = comment.data[0] || {}
553
+ const ups = commentData.ups || []
554
+ const downs = commentData.downs || []
555
+
556
+ let newUps = [...ups]
557
+ let newDowns = [...downs]
558
+
559
+ if (type === 'up') {
560
+ if (ups.includes(uid)) {
561
+ newUps = ups.filter((item) => item !== uid)
562
+ } else {
563
+ newUps.push(uid)
564
+ newDowns = downs.filter((item) => item !== uid)
565
+ }
566
+ } else if (type === 'down') {
567
+ if (downs.includes(uid)) {
568
+ newDowns = downs.filter((item) => item !== uid)
569
+ } else {
570
+ newDowns.push(uid)
571
+ newUps = ups.filter((item) => item !== uid)
572
+ }
545
573
  }
546
- const result = await record.update({ like: likes })
574
+
575
+ const result = await record.update({ ups: newUps, downs: newDowns })
547
576
  return result.updated
548
577
  }
549
578
 
@@ -692,6 +721,16 @@ async function checkCaptcha (comment) {
692
721
  turnstileTokenSecretKey: config.TURNSTILE_SECRET_KEY
693
722
  })
694
723
  }
724
+ if (config.GEETEST_CAPTCHA_ID && config.GEETEST_CAPTCHA_KEY) {
725
+ await checkGeeTestCaptcha({
726
+ geeTestCaptchaId: config.GEETEST_CAPTCHA_ID,
727
+ geeTestCaptchaKey: config.GEETEST_CAPTCHA_KEY,
728
+ geeTestLotNumber: comment.geeTestLotNumber,
729
+ geeTestCaptchaOutput: comment.geeTestCaptchaOutput,
730
+ geeTestPassToken: comment.geeTestPassToken,
731
+ geeTestGenTime: comment.geeTestGenTime
732
+ })
733
+ }
695
734
  }
696
735
 
697
736
  async function saveSpamCheckResult (comment, isSpam) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twikoo-func",
3
- "version": "1.6.44",
3
+ "version": "1.7.0",
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,11 +16,19 @@ 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' })
22
- } else if (config.IMAGE_CDN === 'smms') {
23
- await fn.uploadImageToSmms({ photo, fileName, config, res, imageCdn: 'https://smms.app/api/v2/upload' })
30
+ } else if (config.IMAGE_CDN === 'see') {
31
+ await fn.uploadImageToSee({ photo, fileName, config, res, imageCdn: 'https://s.ee/api/v1/file/upload' })
24
32
  } else if (isUrl(config.IMAGE_CDN)) {
25
33
  await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN })
26
34
  } else if (config.IMAGE_CDN === 'lskypro') {
@@ -29,6 +37,8 @@ const fn = {
29
37
  await fn.uploadImageToPicList({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN_URL })
30
38
  } else if (config.IMAGE_CDN === 'easyimage') {
31
39
  await fn.uploadImageToEasyImage({ photo, fileName, config, res })
40
+ } else if (config.IMAGE_CDN === 'chevereto') {
41
+ await fn.uploadImageToChevereto({ photo, fileName, config, res })
32
42
  } else {
33
43
  throw new Error('不支持的图片上传服务')
34
44
  }
@@ -39,8 +49,35 @@ const fn = {
39
49
  }
40
50
  return res
41
51
  },
42
- async uploadImageToSmms ({ photo, fileName, config, res, imageCdn }) {
43
- // SM.MS 图床 https://sm.ms
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
+ },
79
+ async uploadImageToSee ({ photo, fileName, config, res, imageCdn }) {
80
+ // S.EE 图床 https://s.ee (原 SM.MS)
44
81
  const formData = new FormData()
45
82
  formData.append('smfile', fn.base64UrlToReadStream(photo, fileName))
46
83
  const uploadResult = await axios.post(imageCdn, formData, {
@@ -148,6 +185,35 @@ const fn = {
148
185
  throw new Error(errorMsg)
149
186
  }
150
187
  },
188
+ async uploadImageToChevereto ({ photo, fileName, config, res }) {
189
+ if (!config.IMAGE_CDN_URL) {
190
+ throw new Error('未配置 Chevereto 站点地址 (IMAGE_CDN_URL)')
191
+ }
192
+ if (!config.IMAGE_CDN_TOKEN) {
193
+ throw new Error('未配置 Chevereto API Key (IMAGE_CDN_TOKEN)')
194
+ }
195
+ const formData = new FormData()
196
+ formData.append('key', config.IMAGE_CDN_TOKEN)
197
+ formData.append('source', fn.base64UrlToReadStream(photo, fileName))
198
+ formData.append('format', 'json')
199
+ const apiUrl = config.IMAGE_CDN_URL.replace(/\/$/, '') + '/api/1/upload'
200
+ const uploadResult = await axios.post(apiUrl, formData, {
201
+ headers: {
202
+ ...formData.getHeaders()
203
+ }
204
+ })
205
+ const data = uploadResult.data
206
+ if (data.status_code === 200 && data.image && data.image.url) {
207
+ res.data = {
208
+ url: data.image.url,
209
+ thumb: data.image.thumb ? data.image.thumb.url : data.image.url,
210
+ del: data.image.delete_url
211
+ }
212
+ } else {
213
+ const errMsg = (data.error && data.error.message) || JSON.stringify(data)
214
+ throw new Error(`Chevereto 上传失败: ${errMsg}`)
215
+ }
216
+ },
151
217
  base64UrlToReadStream (base64Url, fileName) {
152
218
  const base64 = base64Url.split(';base64,').pop()
153
219
  const writePath = path.resolve(os.tmpdir(), fileName)
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,
@@ -111,7 +116,8 @@ const fn = {
111
116
  12: 'Monterey',
112
117
  13: 'Ventura',
113
118
  14: 'Sonoma',
114
- 15: 'Sequoia'
119
+ 15: 'Sequoia',
120
+ 16: 'Tahoe'
115
121
  }[majorPlatformVersion]
116
122
  } else if (os.name === 'Android') {
117
123
  const majorPlatformVersion = os.version.split('.')[0]
@@ -303,6 +309,34 @@ const fn = {
303
309
  throw new Error('验证码检测失败: ' + e.message)
304
310
  }
305
311
  },
312
+ async checkGeeTestCaptcha ({ geeTestCaptchaId, geeTestCaptchaKey, geeTestLotNumber, geeTestCaptchaOutput, geeTestPassToken, geeTestGenTime }) {
313
+ try {
314
+ logger.log('极验验证参数:', { geeTestCaptchaId, geeTestCaptchaKey: geeTestCaptchaKey ? '***' : undefined, geeTestLotNumber })
315
+ const crypto = require('crypto')
316
+ const signToken = crypto
317
+ .createHmac('sha256', geeTestCaptchaKey)
318
+ .update(geeTestLotNumber)
319
+ .digest('hex')
320
+ const params = new URLSearchParams()
321
+ params.append('lot_number', geeTestLotNumber)
322
+ params.append('captcha_output', geeTestCaptchaOutput)
323
+ params.append('pass_token', geeTestPassToken)
324
+ params.append('gen_time', geeTestGenTime)
325
+ params.append('sign_token', signToken)
326
+ logger.log('极验请求参数:', params.toString())
327
+ const url = `https://gcaptcha4.geetest.com/validate?captcha_id=${geeTestCaptchaId}`
328
+ const { data } = await axios.post(url, params.toString(), {
329
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
330
+ })
331
+ logger.log('极验验证码检测结果', JSON.stringify(data))
332
+ if (data.result !== 'success') {
333
+ logger.error('极验验证失败详情:', data)
334
+ throw new Error(data.reason || data.msg || '验证码错误')
335
+ }
336
+ } catch (e) {
337
+ throw new Error('极验验证码检测失败: ' + e.message)
338
+ }
339
+ },
306
340
  async getConfig ({ config, VERSION, isAdmin }) {
307
341
  return {
308
342
  code: RES_CODE.SUCCESS,
@@ -328,7 +362,9 @@ const fn = {
328
362
  HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
329
363
  HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
330
364
  LIMIT_LENGTH: config.LIMIT_LENGTH,
331
- TURNSTILE_SITE_KEY: config.TURNSTILE_SITE_KEY
365
+ TURNSTILE_SITE_KEY: config.TURNSTILE_SITE_KEY,
366
+ GEETEST_CAPTCHA_ID: config.GEETEST_CAPTCHA_ID,
367
+ QQ_API_KEY: config.QQ_API_KEY
332
368
  }
333
369
  }
334
370
  },