twikoo-func 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -26,6 +26,7 @@ const {
26
26
  isQQ,
27
27
  addQQMailSuffix,
28
28
  getQQAvatar,
29
+ getQQNick,
29
30
  getPasswordStatus,
30
31
  preCheckSpam,
31
32
  checkTurnstileCaptcha,
@@ -138,6 +139,9 @@ exports.main = async (event, context) => {
138
139
  case 'UPLOAD_IMAGE': // >= 1.5.0
139
140
  res = await uploadImage(event, config)
140
141
  break
142
+ case 'GET_QQ_NICK': // >= 1.7.0
143
+ res = await qqNickGet(event)
144
+ break
141
145
  case 'COMMENT_EXPORT_FOR_ADMIN': // >= 1.6.13
142
146
  res = await commentExportForAdmin(event)
143
147
  break
@@ -714,14 +718,14 @@ async function limitFilter () {
714
718
  }
715
719
 
716
720
  async function checkCaptcha (comment) {
717
- 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) {
718
723
  await checkTurnstileCaptcha({
719
724
  ip: auth.getClientIP(),
720
725
  turnstileToken: comment.turnstileToken,
721
726
  turnstileTokenSecretKey: config.TURNSTILE_SECRET_KEY
722
727
  })
723
- }
724
- if (config.GEETEST_CAPTCHA_ID && config.GEETEST_CAPTCHA_KEY) {
728
+ } else if ((!provider || provider === 'Geetest') && config.GEETEST_CAPTCHA_ID && config.GEETEST_CAPTCHA_KEY) {
725
729
  await checkGeeTestCaptcha({
726
730
  geeTestCaptchaId: config.GEETEST_CAPTCHA_ID,
727
731
  geeTestCaptchaKey: config.GEETEST_CAPTCHA_KEY,
@@ -879,6 +883,21 @@ async function getRecentComments (event) {
879
883
  return res
880
884
  }
881
885
 
886
+ // 获取 QQ 昵称
887
+ async function qqNickGet (event) {
888
+ const res = {}
889
+ try {
890
+ validate(event, ['qq'])
891
+ const nick = await getQQNick(event.qq, config.QQ_API_KEY)
892
+ res.code = RES_CODE.SUCCESS
893
+ res.nick = nick
894
+ } catch (e) {
895
+ res.code = RES_CODE.FAIL
896
+ res.message = e.message
897
+ }
898
+ return res
899
+ }
900
+
882
901
  // 修改配置
883
902
  async function setConfig (event) {
884
903
  const isAdminUser = await isAdmin()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twikoo-func",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "A simple comment system.",
5
5
  "author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
6
6
  "license": "MIT",
package/utils/image.js CHANGED
@@ -12,8 +12,14 @@ const fn = {
12
12
  async uploadImage (event, config) {
13
13
  const { photo, fileName } = event
14
14
  const res = {}
15
+ const imageService = config.IMAGE_SERVICE || config.IMAGE_CDN
15
16
  try {
16
- if (!config.IMAGE_CDN || !config.IMAGE_CDN_TOKEN) {
17
+ if (imageService === 's3') {
18
+ // S3 图床只需要配置相关 S3 参数,不需要 IMAGE_CDN_TOKEN
19
+ if (!config.S3_BUCKET || !config.S3_ACCESS_KEY_ID || !config.S3_SECRET_ACCESS_KEY) {
20
+ throw new Error('未配置 S3 图床参数(S3_BUCKET、S3_ACCESS_KEY_ID、S3_SECRET_ACCESS_KEY)')
21
+ }
22
+ } else if (!imageService || !config.IMAGE_CDN_TOKEN) {
17
23
  throw new Error('未配置图片上传服务')
18
24
  }
19
25
  if (config.NSFW_API_URL) {
@@ -25,20 +31,22 @@ const fn = {
25
31
  }
26
32
  }
27
33
  // tip: qcloud 图床走前端上传,其他图床走后端上传
28
- if (config.IMAGE_CDN === '7bu') {
34
+ if (imageService === '7bu') {
29
35
  await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: 'https://7bu.top' })
30
- } else if (config.IMAGE_CDN === 'see') {
36
+ } else if (imageService === 'see') {
31
37
  await fn.uploadImageToSee({ photo, fileName, config, res, imageCdn: 'https://s.ee/api/v1/file/upload' })
32
- } else if (isUrl(config.IMAGE_CDN)) {
33
- await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN })
34
- } else if (config.IMAGE_CDN === 'lskypro') {
38
+ } else if (isUrl(imageService)) {
39
+ await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: imageService })
40
+ } else if (imageService === 'lskypro') {
35
41
  await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN_URL })
36
- } else if (config.IMAGE_CDN === 'piclist') {
42
+ } else if (imageService === 'piclist') {
37
43
  await fn.uploadImageToPicList({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN_URL })
38
- } else if (config.IMAGE_CDN === 'easyimage') {
44
+ } else if (imageService === 'easyimage') {
39
45
  await fn.uploadImageToEasyImage({ photo, fileName, config, res })
40
- } else if (config.IMAGE_CDN === 'chevereto') {
46
+ } else if (imageService === 'chevereto') {
41
47
  await fn.uploadImageToChevereto({ photo, fileName, config, res })
48
+ } else if (imageService === 's3') {
49
+ await fn.uploadImageToS3({ photo, fileName, config, res })
42
50
  } else {
43
51
  throw new Error('不支持的图片上传服务')
44
52
  }
@@ -214,6 +222,96 @@ const fn = {
214
222
  throw new Error(`Chevereto 上传失败: ${errMsg}`)
215
223
  }
216
224
  },
225
+ async uploadImageToS3 ({ photo, fileName, config, res }) {
226
+ // 使用原生 crypto + axios 实现 AWS Signature V4,无需引入 SDK
227
+ if (!config.S3_BUCKET) {
228
+ throw new Error('未配置 S3 存储桶名称 (S3_BUCKET)')
229
+ }
230
+ if (!config.S3_ACCESS_KEY_ID) {
231
+ throw new Error('未配置 S3 Access Key ID (S3_ACCESS_KEY_ID)')
232
+ }
233
+ if (!config.S3_SECRET_ACCESS_KEY) {
234
+ throw new Error('未配置 S3 Secret Access Key (S3_SECRET_ACCESS_KEY)')
235
+ }
236
+ const crypto = require('crypto')
237
+ const region = config.S3_REGION || 'us-east-1'
238
+ // 解析 base64 图片数据
239
+ const base64 = photo.split(';base64,').pop()
240
+ const mimeType = photo.split(';base64,')[0].replace('data:', '') || 'image/webp'
241
+ const body = Buffer.from(base64, 'base64')
242
+ // 构建对象 key
243
+ const prefix = config.S3_PATH_PREFIX ? config.S3_PATH_PREFIX.replace(/\/$/, '') + '/' : ''
244
+ const key = `${prefix}${Date.now()}-${fileName}`
245
+ let endpoint
246
+ if (config.S3_ENDPOINT) {
247
+ // 兼容 R2
248
+ endpoint = `${config.S3_ENDPOINT.replace(/\/$/, '')}/${config.S3_BUCKET}/${key}`
249
+ } else {
250
+ // 标准 AWS S3:virtual-hosted-style URL
251
+ endpoint = `https://${config.S3_BUCKET}.s3.${region}.amazonaws.com/${key}`
252
+ }
253
+ const endpointUrl = new URL(endpoint)
254
+ const host = endpointUrl.host
255
+ const pathname = endpointUrl.pathname
256
+ const now = new Date()
257
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
258
+ const amzDate = now.toISOString().replace(/[:-]/g, '').slice(0, 15) + 'Z'
259
+ const payloadHash = crypto.createHash('sha256').update(body).digest('hex')
260
+ const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
261
+ const canonicalHeaders = [
262
+ `content-type:${mimeType}`,
263
+ `host:${host}`,
264
+ `x-amz-content-sha256:${payloadHash}`,
265
+ `x-amz-date:${amzDate}`
266
+ ].join('\n') + '\n'
267
+ const canonicalRequest = [
268
+ 'PUT',
269
+ pathname,
270
+ '', // query string
271
+ canonicalHeaders,
272
+ signedHeaders,
273
+ payloadHash
274
+ ].join('\n')
275
+ const credentialScope = `${dateStamp}/${region}/s3/aws4_request`
276
+ const stringToSign = [
277
+ 'AWS4-HMAC-SHA256',
278
+ amzDate,
279
+ credentialScope,
280
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex')
281
+ ].join('\n')
282
+ const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest()
283
+ const signingKey = hmac(
284
+ hmac(
285
+ hmac(
286
+ hmac(Buffer.from('AWS4' + config.S3_SECRET_ACCESS_KEY), dateStamp),
287
+ region
288
+ ),
289
+ 's3'
290
+ ),
291
+ 'aws4_request'
292
+ )
293
+ const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex')
294
+ const authorization = `AWS4-HMAC-SHA256 Credential=${config.S3_ACCESS_KEY_ID}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
295
+ await axios.put(endpoint, body, {
296
+ headers: {
297
+ 'Content-Type': mimeType,
298
+ 'x-amz-content-sha256': payloadHash,
299
+ 'x-amz-date': amzDate,
300
+ Authorization: authorization
301
+ },
302
+ maxBodyLength: Infinity
303
+ })
304
+ // 构建访问 URL
305
+ let fileUrl
306
+ if (config.S3_CDN_URL) {
307
+ fileUrl = `${config.S3_CDN_URL.replace(/\/$/, '')}/${key}`
308
+ } else if (config.S3_ENDPOINT) {
309
+ fileUrl = `${config.S3_ENDPOINT.replace(/\/$/, '')}/${config.S3_BUCKET}/${key}`
310
+ } else {
311
+ fileUrl = `https://${config.S3_BUCKET}.s3.${region}.amazonaws.com/${key}`
312
+ }
313
+ res.data = { url: fileUrl }
314
+ },
217
315
  base64UrlToReadStream (base64Url, fileName) {
218
316
  const base64 = base64Url.split(';base64,').pop()
219
317
  const writePath = path.resolve(os.tmpdir(), fileName)
package/utils/index.js CHANGED
@@ -249,6 +249,23 @@ const fn = {
249
249
  logger.warn('获取 QQ 头像失败:', e)
250
250
  }
251
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
+ },
252
269
  // 判断是否存在管理员密码
253
270
  async getPasswordStatus (config, version) {
254
271
  return {
@@ -338,34 +355,49 @@ const fn = {
338
355
  }
339
356
  },
340
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
+ IMAGE_SERVICE: config.IMAGE_SERVICE,
371
+ LIGHTBOX: config.LIGHTBOX || 'false',
372
+ SHOW_EMOTION: config.SHOW_EMOTION || 'true',
373
+ EMOTION_CDN: config.EMOTION_CDN,
374
+ COMMENT_PLACEHOLDER: config.COMMENT_PLACEHOLDER,
375
+ SHOW_ORDER: config.SHOW_ORDER || 'true',
376
+ SHOW_DISLIKE: config.SHOW_DISLIKE || 'true',
377
+ DISPLAYED_FIELDS: config.DISPLAYED_FIELDS,
378
+ REQUIRED_FIELDS: config.REQUIRED_FIELDS,
379
+ HIDE_ADMIN_CRYPT: config.HIDE_ADMIN_CRYPT,
380
+ HIGHLIGHT: config.HIGHLIGHT || 'true',
381
+ HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
382
+ HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
383
+ LIMIT_LENGTH: config.LIMIT_LENGTH,
384
+ CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER,
385
+ QQ_API_KEY: config.QQ_API_KEY
386
+ }
387
+
388
+ // 仅在明确指定使用 Turnstile 时下发 Turnstile 的 site key
389
+ if (config.CAPTCHA_PROVIDER === 'Turnstile') {
390
+ baseConfig.TURNSTILE_SITE_KEY = config.TURNSTILE_SITE_KEY
391
+ }
392
+
393
+ // 仅在明确指定使用 Geetest 时下发 Geetest 的 id
394
+ if (config.CAPTCHA_PROVIDER === 'Geetest') {
395
+ baseConfig.GEETEST_CAPTCHA_ID = config.GEETEST_CAPTCHA_ID
396
+ }
397
+
341
398
  return {
342
399
  code: RES_CODE.SUCCESS,
343
- config: {
344
- VERSION,
345
- IS_ADMIN: isAdmin,
346
- SITE_NAME: config.SITE_NAME,
347
- SITE_URL: config.SITE_URL,
348
- MASTER_TAG: config.MASTER_TAG,
349
- COMMENT_BG_IMG: config.COMMENT_BG_IMG,
350
- GRAVATAR_CDN: config.GRAVATAR_CDN,
351
- DEFAULT_GRAVATAR: config.DEFAULT_GRAVATAR,
352
- SHOW_IMAGE: config.SHOW_IMAGE || 'true',
353
- IMAGE_CDN: config.IMAGE_CDN,
354
- LIGHTBOX: config.LIGHTBOX || 'false',
355
- SHOW_EMOTION: config.SHOW_EMOTION || 'true',
356
- EMOTION_CDN: config.EMOTION_CDN,
357
- COMMENT_PLACEHOLDER: config.COMMENT_PLACEHOLDER,
358
- DISPLAYED_FIELDS: config.DISPLAYED_FIELDS,
359
- REQUIRED_FIELDS: config.REQUIRED_FIELDS,
360
- HIDE_ADMIN_CRYPT: config.HIDE_ADMIN_CRYPT,
361
- HIGHLIGHT: config.HIGHLIGHT || 'true',
362
- HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
363
- HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
364
- LIMIT_LENGTH: config.LIMIT_LENGTH,
365
- TURNSTILE_SITE_KEY: config.TURNSTILE_SITE_KEY,
366
- GEETEST_CAPTCHA_ID: config.GEETEST_CAPTCHA_ID,
367
- QQ_API_KEY: config.QQ_API_KEY
368
- }
400
+ config: baseConfig
369
401
  }
370
402
  },
371
403
  async getConfigForAdmin ({ config, isAdmin }) {