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/README.md +41 -4
- package/dist/index.d.mts +104 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +215 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +210 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +40 -22
- package/index.js +0 -1178
- package/utils/cap.js +0 -311
- package/utils/constants.js +0 -20
- package/utils/image.js +0 -376
- package/utils/import.js +0 -290
- package/utils/index.js +0 -512
- package/utils/lib.js +0 -79
- package/utils/logger.js +0 -22
- package/utils/notify.js +0 -309
- package/utils/spam.js +0 -241
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
|
-
}
|
package/utils/constants.js
DELETED
|
@@ -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
|