twikoo-func 1.7.24 → 2.0.0-beta.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/utils/cap.js DELETED
@@ -1,311 +0,0 @@
1
- /**
2
- * Embedded Cap.js (https://capjs.js.org) — no external Cap Standalone required.
3
- * Storage adapters follow the same hooks as @cap.js/server / ink-battles.
4
- */
5
-
6
- const Cap = require('@cap.js/server')
7
- const logger = require('./logger')
8
-
9
- const CHALLENGE_OPTS = {
10
- challengeCount: 50,
11
- challengeSize: 32,
12
- challengeDifficulty: 4,
13
- expiresMs: 600000 // 10 min
14
- }
15
-
16
- function memoryStorage () {
17
- const challenges = new Map()
18
- const tokens = new Map()
19
- return {
20
- challenges: {
21
- store: async (token, data) => { challenges.set(token, data) },
22
- read: async (token) => challenges.get(token) || null,
23
- delete: async (token) => { challenges.delete(token) },
24
- deleteExpired: async () => {
25
- const now = Date.now()
26
- for (const [k, v] of challenges) {
27
- if (!v || v.expires < now) challenges.delete(k)
28
- }
29
- }
30
- },
31
- tokens: {
32
- store: async (key, expires) => { tokens.set(key, expires) },
33
- get: async (key) => {
34
- const exp = tokens.get(key)
35
- if (!exp) return null
36
- if (exp < Date.now()) {
37
- tokens.delete(key)
38
- return null
39
- }
40
- return exp
41
- },
42
- delete: async (key) => { tokens.delete(key) },
43
- deleteExpired: async () => {
44
- const now = Date.now()
45
- for (const [k, v] of tokens) {
46
- if (v < now) tokens.delete(k)
47
- }
48
- }
49
- }
50
- }
51
- }
52
-
53
- /** MongoDB (native driver) — Vercel / self-hosted mongo */
54
- function mongoStorage (db) {
55
- const challenges = () => db.collection('cap_challenges')
56
- const tokens = () => db.collection('cap_tokens')
57
- let indexed = false
58
- const ensureIndex = () => {
59
- if (indexed) return
60
- indexed = true
61
- challenges().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => {})
62
- tokens().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => {})
63
- }
64
- return {
65
- challenges: {
66
- store: async (token, data) => {
67
- ensureIndex()
68
- await challenges().updateOne(
69
- { token },
70
- { $set: { token, challenge: data.challenge, expiresAt: new Date(data.expires) } },
71
- { upsert: true }
72
- )
73
- },
74
- read: async (token) => {
75
- const doc = await challenges().findOne({ token, expiresAt: { $gt: new Date() } })
76
- return doc ? { challenge: doc.challenge, expires: doc.expiresAt.getTime() } : null
77
- },
78
- delete: async (token) => { await challenges().deleteOne({ token }) },
79
- deleteExpired: async () => { await challenges().deleteMany({ expiresAt: { $lte: new Date() } }) }
80
- },
81
- tokens: {
82
- store: async (key, expires) => {
83
- ensureIndex()
84
- await tokens().updateOne(
85
- { key },
86
- { $set: { key, expiresAt: new Date(expires) } },
87
- { upsert: true }
88
- )
89
- },
90
- get: async (key) => {
91
- const doc = await tokens().findOne({ key, expiresAt: { $gt: new Date() } })
92
- return doc ? doc.expiresAt.getTime() : null
93
- },
94
- delete: async (key) => { await tokens().deleteOne({ key }) },
95
- deleteExpired: async () => { await tokens().deleteMany({ expiresAt: { $lte: new Date() } }) }
96
- }
97
- }
98
- }
99
-
100
- /** LokiJS — self-hosted default */
101
- function lokiStorage (db) {
102
- const getCol = (name) => {
103
- let col = db.getCollection(name)
104
- if (!col) col = db.addCollection(name, { unique: ['key'] })
105
- return col
106
- }
107
- // challenges use token field; tokens use key field
108
- const getChallengeCol = () => {
109
- let col = db.getCollection('cap_challenges')
110
- if (!col) col = db.addCollection('cap_challenges', { unique: ['token'] })
111
- return col
112
- }
113
- const getTokenCol = () => getCol('cap_tokens')
114
- return {
115
- challenges: {
116
- store: async (token, data) => {
117
- const col = getChallengeCol()
118
- const existing = col.findOne({ token })
119
- const row = { token, challenge: data.challenge, expires: data.expires }
120
- if (existing) {
121
- Object.assign(existing, row)
122
- col.update(existing)
123
- } else col.insert(row)
124
- },
125
- read: async (token) => {
126
- const doc = getChallengeCol().findOne({ token })
127
- if (!doc || doc.expires < Date.now()) return null
128
- return { challenge: doc.challenge, expires: doc.expires }
129
- },
130
- delete: async (token) => { getChallengeCol().findAndRemove({ token }) },
131
- deleteExpired: async () => {
132
- getChallengeCol().findAndRemove({ expires: { $lte: Date.now() } })
133
- }
134
- },
135
- tokens: {
136
- store: async (key, expires) => {
137
- const col = getTokenCol()
138
- const existing = col.findOne({ key })
139
- const row = { key, expires }
140
- if (existing) {
141
- Object.assign(existing, row)
142
- col.update(existing)
143
- } else col.insert(row)
144
- },
145
- get: async (key) => {
146
- const doc = getTokenCol().findOne({ key })
147
- if (!doc || doc.expires < Date.now()) return null
148
- return doc.expires
149
- },
150
- delete: async (key) => { getTokenCol().findAndRemove({ key }) },
151
- deleteExpired: async () => {
152
- getTokenCol().findAndRemove({ expires: { $lte: Date.now() } })
153
- }
154
- }
155
- }
156
- }
157
-
158
- /** CloudBase / TCB database */
159
- function tcbStorage (db) {
160
- const _ = db.command
161
- return {
162
- challenges: {
163
- store: async (token, data) => {
164
- const col = db.collection('cap_challenges')
165
- try {
166
- await col.doc(token).set({
167
- token,
168
- challenge: data.challenge,
169
- expires: data.expires
170
- })
171
- } catch (e) {
172
- // doc may exist
173
- await col.doc(token).update({
174
- challenge: data.challenge,
175
- expires: data.expires
176
- })
177
- }
178
- },
179
- read: async (token) => {
180
- try {
181
- const res = await db.collection('cap_challenges').doc(token).get()
182
- const doc = res.data && res.data[0]
183
- if (!doc || doc.expires < Date.now()) return null
184
- return { challenge: doc.challenge, expires: doc.expires }
185
- } catch (e) {
186
- return null
187
- }
188
- },
189
- delete: async (token) => {
190
- try { await db.collection('cap_challenges').doc(token).remove() } catch (e) {}
191
- },
192
- deleteExpired: async () => {
193
- try {
194
- await db.collection('cap_challenges').where({ expires: _.lte(Date.now()) }).remove()
195
- } catch (e) {}
196
- }
197
- },
198
- tokens: {
199
- store: async (key, expires) => {
200
- // CloudBase doc id cannot contain ':'
201
- const id = key.replace(/:/g, '_')
202
- try {
203
- await db.collection('cap_tokens').doc(id).set({ key, expires })
204
- } catch (e) {
205
- await db.collection('cap_tokens').doc(id).update({ key, expires })
206
- }
207
- },
208
- get: async (key) => {
209
- try {
210
- const id = key.replace(/:/g, '_')
211
- const res = await db.collection('cap_tokens').doc(id).get()
212
- const doc = res.data && res.data[0]
213
- if (!doc || doc.expires < Date.now()) return null
214
- return doc.expires
215
- } catch (e) {
216
- return null
217
- }
218
- },
219
- delete: async (key) => {
220
- try {
221
- const id = key.replace(/:/g, '_')
222
- await db.collection('cap_tokens').doc(id).remove()
223
- } catch (e) {}
224
- },
225
- deleteExpired: async () => {
226
- try {
227
- await db.collection('cap_tokens').where({ expires: _.lte(Date.now()) }).remove()
228
- } catch (e) {}
229
- }
230
- }
231
- }
232
- }
233
-
234
- /**
235
- * EdgeOne Blob-style store: expects { get(key), set(key, value), del(key) }
236
- * value is JSON-serializable.
237
- */
238
- function kvStorage (kv) {
239
- return {
240
- challenges: {
241
- store: async (token, data) => { await kv.set(`cap:c:${token}`, data) },
242
- read: async (token) => {
243
- const data = await kv.get(`cap:c:${token}`)
244
- if (!data || data.expires < Date.now()) return null
245
- return data
246
- },
247
- delete: async (token) => { await kv.del(`cap:c:${token}`) },
248
- deleteExpired: async () => {}
249
- },
250
- tokens: {
251
- store: async (key, expires) => { await kv.set(`cap:t:${key}`, { expires }) },
252
- get: async (key) => {
253
- const data = await kv.get(`cap:t:${key}`)
254
- if (!data || data.expires < Date.now()) return null
255
- return data.expires
256
- },
257
- delete: async (key) => { await kv.del(`cap:t:${key}`) },
258
- deleteExpired: async () => {}
259
- }
260
- }
261
- }
262
-
263
- function createCap (storage) {
264
- return new Cap({
265
- noFSState: true,
266
- storage: storage || memoryStorage()
267
- })
268
- }
269
-
270
- async function createChallenge (cap) {
271
- return cap.createChallenge(CHALLENGE_OPTS)
272
- }
273
-
274
- async function redeemChallenge (cap, body) {
275
- const token = body && body.token
276
- const solutions = body && body.solutions
277
- if (!token || !solutions || !Array.isArray(solutions)) {
278
- return { success: false, error: 'Missing token or solutions' }
279
- }
280
- return cap.redeemChallenge({ token, solutions })
281
- }
282
-
283
- async function validateToken (cap, token) {
284
- if (!token) return false
285
- try {
286
- const { success } = await cap.validateToken(token)
287
- return !!success
288
- } catch (e) {
289
- logger.error('Cap validateToken failed:', e)
290
- return false
291
- }
292
- }
293
-
294
- /** CAPTCHA_PROVIDER=Cap and no CAP_API_ENDPOINT → use embedded Cap */
295
- function isBuiltinCap (config) {
296
- return config && config.CAPTCHA_PROVIDER === 'Cap' && !config.CAP_API_ENDPOINT
297
- }
298
-
299
- module.exports = {
300
- createCap,
301
- memoryStorage,
302
- mongoStorage,
303
- lokiStorage,
304
- tcbStorage,
305
- kvStorage,
306
- createChallenge,
307
- redeemChallenge,
308
- validateToken,
309
- isBuiltinCap,
310
- CHALLENGE_OPTS
311
- }
@@ -1,20 +0,0 @@
1
- module.exports = {
2
- RES_CODE: {
3
- SUCCESS: 0,
4
- NO_PARAM: 100,
5
- FAIL: 1000,
6
- EVENT_NOT_EXIST: 1001,
7
- PASS_EXIST: 1010,
8
- CONFIG_NOT_EXIST: 1020,
9
- CREDENTIALS_NOT_EXIST: 1021,
10
- CREDENTIALS_INVALID: 1025,
11
- PASS_NOT_EXIST: 1022,
12
- PASS_NOT_MATCH: 1023,
13
- NEED_LOGIN: 1024,
14
- FORBIDDEN: 1403,
15
- AKISMET_ERROR: 1030,
16
- UPLOAD_FAILED: 1040,
17
- NSFW_REJECTED: 1041
18
- },
19
- MAX_REQUEST_TIMES: parseInt(process.env.TWIKOO_THROTTLE) || 250
20
- }
package/utils/image.js DELETED
@@ -1,376 +0,0 @@
1
- const crypto = require('crypto')
2
- const { isUrl } = require('.')
3
- const { RES_CODE } = require('./constants')
4
- const { getAxios, getFormData } = require('./lib')
5
- const axios = getAxios()
6
- const FormData = getFormData()
7
- const logger = require('./logger')
8
-
9
- const MAX_IMAGE_SIZE = 10 * 1024 * 1024
10
- const IMAGE_TYPES = [
11
- {
12
- mimeType: 'image/jpeg',
13
- extension: 'jpg',
14
- matches: (body) => body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff
15
- },
16
- {
17
- mimeType: 'image/png',
18
- extension: 'png',
19
- matches: (body) => body.length >= 8 && body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
20
- },
21
- {
22
- mimeType: 'image/gif',
23
- extension: 'gif',
24
- matches: (body) => body.length >= 6 && (body.subarray(0, 6).equals(Buffer.from('GIF87a')) || body.subarray(0, 6).equals(Buffer.from('GIF89a')))
25
- },
26
- {
27
- mimeType: 'image/webp',
28
- extension: 'webp',
29
- matches: (body) => body.length >= 12 && body.subarray(0, 4).equals(Buffer.from('RIFF')) && body.subarray(8, 12).equals(Buffer.from('WEBP'))
30
- }
31
- ]
32
-
33
- const fn = {
34
- async uploadImage (event, config) {
35
- const { photo } = event
36
- const res = {}
37
- const imageService = config.IMAGE_CDN
38
- try {
39
- if (imageService === 's3') {
40
- // S3 图床只需要配置相关 S3 参数,不需要 IMAGE_CDN_TOKEN
41
- if (!config.S3_BUCKET || !config.S3_ACCESS_KEY_ID || !config.S3_SECRET_ACCESS_KEY) {
42
- throw new Error('未配置 S3 图床参数(S3_BUCKET、S3_ACCESS_KEY_ID、S3_SECRET_ACCESS_KEY)')
43
- }
44
- } else if (!imageService || !config.IMAGE_CDN_TOKEN) {
45
- throw new Error('未配置图片上传服务')
46
- }
47
- const image = fn.parseImage(photo)
48
- if (config.NSFW_API_URL) {
49
- const nsfwResult = await fn.checkNsfw({ image, config })
50
- if (nsfwResult.rejected) {
51
- res.code = RES_CODE.NSFW_REJECTED
52
- res.err = nsfwResult.message
53
- return res
54
- }
55
- }
56
- // tip: qcloud 图床走前端上传,其他图床走后端上传
57
- if (imageService === '7bu') {
58
- await fn.uploadImageToLskyPro({ image, config, res, imageCdn: 'https://7bu.top' })
59
- } else if (imageService === 'see') {
60
- await fn.uploadImageToSee({ image, config, res, imageCdn: 'https://s.ee/api/v1/file/upload' })
61
- } else if (isUrl(imageService)) {
62
- await fn.uploadImageToLskyPro({ image, config, res, imageCdn: imageService })
63
- } else if (imageService === 'lskypro') {
64
- await fn.uploadImageToLskyPro({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
65
- } else if (imageService === 'piclist') {
66
- await fn.uploadImageToPicList({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
67
- } else if (imageService === 'easyimage') {
68
- await fn.uploadImageToEasyImage({ image, config, res })
69
- } else if (imageService === 'chevereto') {
70
- await fn.uploadImageToChevereto({ image, config, res })
71
- } else if (imageService === 's3') {
72
- await fn.uploadImageToS3({ image, config, res })
73
- } else {
74
- throw new Error('不支持的图片上传服务')
75
- }
76
- } catch (e) {
77
- logger.error(e)
78
- res.code = RES_CODE.UPLOAD_FAILED
79
- res.err = e.message
80
- }
81
- return res
82
- },
83
- async checkNsfw ({ image, config }) {
84
- const result = { rejected: false, message: '' }
85
- try {
86
- const threshold = parseFloat(config.NSFW_THRESHOLD) || 0.5
87
- const apiUrl = config.NSFW_API_URL.replace(/\/$/, '')
88
- const formData = new FormData()
89
- fn.appendImage(formData, 'image', image)
90
- const response = await axios.post(`${apiUrl}/classify`, formData, {
91
- headers: {
92
- ...formData.getHeaders()
93
- },
94
- timeout: 30000
95
- })
96
- const scores = response.data
97
- if (scores && typeof scores === 'object') {
98
- const nsfwScore = (scores.porn || 0) + (scores.hentai || 0) + (scores.sexy || 0)
99
- logger.info('NSFW检测分数:', nsfwScore, '阈值:', threshold)
100
- if (nsfwScore > threshold) {
101
- result.rejected = true
102
- result.message = `图片包含不当内容,检测分数 ${nsfwScore.toFixed(3)} 超过阈值 ${threshold}`
103
- }
104
- }
105
- } catch (e) {
106
- logger.error('NSFW检测失败:', e.message)
107
- }
108
- return result
109
- },
110
- async uploadImageToSee ({ image, config, res, imageCdn }) {
111
- // S.EE 图床 https://s.ee (原 SM.MS)
112
- const formData = new FormData()
113
- fn.appendImage(formData, 'smfile', image)
114
- const uploadResult = await axios.post(imageCdn, formData, {
115
- headers: {
116
- ...formData.getHeaders(),
117
- Authorization: config.IMAGE_CDN_TOKEN
118
- }
119
- })
120
- if (uploadResult.data.success) {
121
- res.data = uploadResult.data.data
122
- } else {
123
- throw new Error(uploadResult.data.message)
124
- }
125
- },
126
- async uploadImageToLskyPro ({ image, config, res, imageCdn }) {
127
- // 自定义兰空图床(v2)URL
128
- const formData = new FormData()
129
- fn.appendImage(formData, 'file', image)
130
- if (process.env.TWIKOO_LSKY_STRATEGY_ID) {
131
- formData.append('strategy_id', parseInt(process.env.TWIKOO_LSKY_STRATEGY_ID))
132
- }
133
- const url = `${imageCdn}/api/v1/upload`
134
- let token = config.IMAGE_CDN_TOKEN
135
- if (!token.startsWith('Bearer')) {
136
- token = `Bearer ${token}`
137
- }
138
- const uploadResult = await axios.post(url, formData, {
139
- headers: {
140
- ...formData.getHeaders(),
141
- Authorization: token
142
- }
143
- })
144
- if (uploadResult.data.status) {
145
- res.data = uploadResult.data.data
146
- res.data.url = res.data.links.url
147
- } else {
148
- throw new Error(uploadResult.data.message)
149
- }
150
- },
151
- async uploadImageToPicList ({ image, config, res, imageCdn }) {
152
- // PicList https://piclist.cn/ 高效的云存储和图床平台管理工具
153
- // 鉴权使用 query 参数 key
154
- const formData = new FormData()
155
- fn.appendImage(formData, 'file', image)
156
- let url = `${imageCdn}/upload`
157
- // 如果填写了 key 则拼接 url
158
- if (config.IMAGE_CDN_TOKEN) {
159
- url += `?key=${config.IMAGE_CDN_TOKEN}`
160
- }
161
- const uploadResult = await axios.post(url, formData)
162
- if (uploadResult.data.success) {
163
- res.data = uploadResult.data
164
- res.data.url = uploadResult.data.result[0]
165
- } else {
166
- throw new Error(uploadResult.data.message)
167
- }
168
- },
169
- async uploadImageToEasyImage ({ image, config, res }) {
170
- // EasyImage2.0 https://github.com/icret/EasyImages2.0 简单图床 - 一款功能强大无数据库的图床 2.0版
171
- try {
172
- // 参数校验
173
- if (!config.IMAGE_CDN_URL) {
174
- throw new Error('未配置 EasyImage2.0 的 API 地址 (IMAGE_CDN_URL)')
175
- }
176
- if (!config.IMAGE_CDN_TOKEN) {
177
- throw new Error('未配置 EasyImage2.0 的 Token (IMAGE_CDN_TOKEN)')
178
- }
179
- // 构建固定格式的 FormData
180
- const formData = new FormData()
181
- // 添加 token 参数到 Body
182
- formData.append('token', config.IMAGE_CDN_TOKEN)
183
- // 添加图片文件(固定参数名 image)
184
- fn.appendImage(formData, 'image', image)
185
- // 发送请求
186
- const uploadResult = await axios.post(config.IMAGE_CDN_URL, formData, {
187
- headers: {
188
- ...formData.getHeaders(),
189
- 'User-Agent': 'Twikoo'
190
- }
191
- })
192
- // 解析响应
193
- const response = uploadResult.data
194
- // 检查业务状态码
195
- if (response.code !== 200 || response.result !== 'success') {
196
- throw new Error(`API 返回错误 (CODE: ${response.code})`)
197
- }
198
- // 提取图片 URL(固定 JSON 路径 url)
199
- if (!response.url) {
200
- throw new Error('未找到有效图片 URL')
201
- }
202
- // 返回标准化结构
203
- res.data = {
204
- url: response.url,
205
- thumb: response.thumb, // 可选返回缩略图
206
- del: response.del // 可选返回删除链接
207
- }
208
- } catch (e) {
209
- let errorMsg = `EasyImage2.0 上传失败: ${e.message}`
210
- // 追加 API 返回的错误详情
211
- if (e.response?.data) {
212
- errorMsg += ` | 错误类型: ${e.response.data.message || '未知'}`
213
- }
214
- throw new Error(errorMsg)
215
- }
216
- },
217
- async uploadImageToChevereto ({ image, config, res }) {
218
- if (!config.IMAGE_CDN_URL) {
219
- throw new Error('未配置 Chevereto 站点地址 (IMAGE_CDN_URL)')
220
- }
221
- if (!config.IMAGE_CDN_TOKEN) {
222
- throw new Error('未配置 Chevereto API Key (IMAGE_CDN_TOKEN)')
223
- }
224
- const formData = new FormData()
225
- formData.append('key', config.IMAGE_CDN_TOKEN)
226
- fn.appendImage(formData, 'source', image)
227
- formData.append('format', 'json')
228
- const apiUrl = config.IMAGE_CDN_URL.replace(/\/$/, '') + '/api/1/upload'
229
- const uploadResult = await axios.post(apiUrl, formData, {
230
- headers: {
231
- ...formData.getHeaders()
232
- }
233
- })
234
- const data = uploadResult.data
235
- if (data.status_code === 200 && data.image && data.image.url) {
236
- res.data = {
237
- url: data.image.url,
238
- thumb: data.image.thumb ? data.image.thumb.url : data.image.url,
239
- del: data.image.delete_url
240
- }
241
- } else {
242
- const errMsg = (data.error && data.error.message) || JSON.stringify(data)
243
- throw new Error(`Chevereto 上传失败: ${errMsg}`)
244
- }
245
- },
246
- async uploadImageToS3 ({ image, config, res }) {
247
- // 使用原生 crypto + axios 实现 AWS Signature V4,无需引入 SDK
248
- if (!config.S3_BUCKET) {
249
- throw new Error('未配置 S3 存储桶名称 (S3_BUCKET)')
250
- }
251
- if (!config.S3_ACCESS_KEY_ID) {
252
- throw new Error('未配置 S3 Access Key ID (S3_ACCESS_KEY_ID)')
253
- }
254
- if (!config.S3_SECRET_ACCESS_KEY) {
255
- throw new Error('未配置 S3 Secret Access Key (S3_SECRET_ACCESS_KEY)')
256
- }
257
- const region = config.S3_REGION || 'us-east-1'
258
- const { body, mimeType, fileName } = image
259
- // 构建对象 key
260
- const prefix = config.S3_PATH_PREFIX ? config.S3_PATH_PREFIX.replace(/\/$/, '') + '/' : ''
261
- const key = `${prefix}${fileName}`
262
- const forcePathStyle = String(config.S3_FORCE_PATH_STYLE).trim().toLowerCase() !== 'false'
263
- let endpoint
264
- let s3Base
265
- if (config.S3_ENDPOINT) {
266
- // 自定义 S3 Endpoint
267
- const endpointBase = config.S3_ENDPOINT.replace(/\/$/, '')
268
- s3Base = forcePathStyle ? `${endpointBase}/${config.S3_BUCKET}` : endpointBase
269
- endpoint = `${s3Base}/${key}`
270
- } else {
271
- // 标准 AWS S3:virtual-hosted-style URL
272
- s3Base = `https://${config.S3_BUCKET}.s3.${region}.amazonaws.com`
273
- endpoint = `${s3Base}/${key}`
274
- }
275
- const endpointUrl = new URL(endpoint)
276
- const host = endpointUrl.host
277
- const pathname = endpointUrl.pathname
278
- const now = new Date()
279
- const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
280
- const amzDate = now.toISOString().replace(/[:-]/g, '').slice(0, 15) + 'Z'
281
- const payloadHash = crypto.createHash('sha256').update(body).digest('hex')
282
- const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
283
- const canonicalHeaders = [
284
- `content-type:${mimeType}`,
285
- `host:${host}`,
286
- `x-amz-content-sha256:${payloadHash}`,
287
- `x-amz-date:${amzDate}`
288
- ].join('\n') + '\n'
289
- const canonicalRequest = [
290
- 'PUT',
291
- pathname,
292
- '', // query string
293
- canonicalHeaders,
294
- signedHeaders,
295
- payloadHash
296
- ].join('\n')
297
- const credentialScope = `${dateStamp}/${region}/s3/aws4_request`
298
- const stringToSign = [
299
- 'AWS4-HMAC-SHA256',
300
- amzDate,
301
- credentialScope,
302
- crypto.createHash('sha256').update(canonicalRequest).digest('hex')
303
- ].join('\n')
304
- const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest()
305
- const signingKey = hmac(
306
- hmac(
307
- hmac(
308
- hmac(Buffer.from('AWS4' + config.S3_SECRET_ACCESS_KEY), dateStamp),
309
- region
310
- ),
311
- 's3'
312
- ),
313
- 'aws4_request'
314
- )
315
- const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex')
316
- const authorization = `AWS4-HMAC-SHA256 Credential=${config.S3_ACCESS_KEY_ID}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
317
- await axios.put(endpoint, body, {
318
- headers: {
319
- 'Content-Type': mimeType,
320
- 'x-amz-content-sha256': payloadHash,
321
- 'x-amz-date': amzDate,
322
- Authorization: authorization
323
- },
324
- maxBodyLength: Infinity
325
- })
326
- // 构建访问 URL
327
- let fileUrl
328
- if (config.S3_CDN_URL) {
329
- fileUrl = `${config.S3_CDN_URL.replace(/\/$/, '')}/${key}`
330
- } else {
331
- fileUrl = `${s3Base}/${key}`
332
- }
333
- res.data = { url: fileUrl }
334
- },
335
- parseImage (photo) {
336
- if (typeof photo !== 'string' || photo.length > Math.ceil(MAX_IMAGE_SIZE / 3) * 4 + 64) {
337
- throw new Error('图片大小不能超过 10 MB')
338
- }
339
- const header = /^data:([a-z0-9.+-]+\/[a-z0-9.+-]+);base64,/i.exec(photo)
340
- if (!header) {
341
- throw new Error('图片数据格式不合法')
342
- }
343
- const declaredMimeType = header[1].toLowerCase()
344
- const base64 = photo.slice(header[0].length)
345
- if (!base64 || base64.length % 4 !== 0 || !/^[a-z0-9+/]*={0,2}$/i.test(base64)) {
346
- throw new Error('图片数据格式不合法')
347
- }
348
- const padding = base64.endsWith('==') ? 2 : (base64.endsWith('=') ? 1 : 0)
349
- const decodedSize = base64.length * 3 / 4 - padding
350
- if (decodedSize > MAX_IMAGE_SIZE) {
351
- throw new Error('图片大小不能超过 10 MB')
352
- }
353
- const body = Buffer.from(base64, 'base64')
354
- const imageType = IMAGE_TYPES.find((type) => type.matches(body))
355
- if (!imageType) {
356
- throw new Error('仅支持 JPEG、PNG、GIF 和 WebP 图片')
357
- }
358
- if (declaredMimeType !== imageType.mimeType) {
359
- throw new Error('图片 MIME 类型与文件内容不匹配')
360
- }
361
- return {
362
- body,
363
- mimeType: imageType.mimeType,
364
- fileName: `${crypto.randomBytes(16).toString('hex')}.${imageType.extension}`
365
- }
366
- },
367
- appendImage (formData, fieldName, image) {
368
- formData.append(fieldName, image.body, {
369
- filename: image.fileName,
370
- contentType: image.mimeType,
371
- knownLength: image.body.length
372
- })
373
- }
374
- }
375
-
376
- module.exports = fn