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 +22 -3
- package/package.json +1 -1
- package/utils/image.js +107 -9
- package/utils/index.js +58 -26
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
|
-
|
|
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
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 (
|
|
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 (
|
|
34
|
+
if (imageService === '7bu') {
|
|
29
35
|
await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: 'https://7bu.top' })
|
|
30
|
-
} else if (
|
|
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(
|
|
33
|
-
await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn:
|
|
34
|
-
} else if (
|
|
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 (
|
|
42
|
+
} else if (imageService === 'piclist') {
|
|
37
43
|
await fn.uploadImageToPicList({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN_URL })
|
|
38
|
-
} else if (
|
|
44
|
+
} else if (imageService === 'easyimage') {
|
|
39
45
|
await fn.uploadImageToEasyImage({ photo, fileName, config, res })
|
|
40
|
-
} else if (
|
|
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 }) {
|