twikoo-func 1.7.14 → 1.7.16

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
@@ -38,6 +38,13 @@ const {
38
38
  checkCommentOwnership,
39
39
  isValidEmail
40
40
  } = require('./utils')
41
+ const {
42
+ createCap,
43
+ tcbStorage,
44
+ createChallenge,
45
+ redeemChallenge,
46
+ isBuiltinCap
47
+ } = require('./utils/cap')
41
48
  const {
42
49
  jsonParse,
43
50
  commentImportValine,
@@ -151,6 +158,12 @@ exports.main = async (event, context) => {
151
158
  case 'COMMENT_DELETE_FOR_USER':
152
159
  res = await commentDeleteForUser(event)
153
160
  break
161
+ case 'CAP_CHALLENGE': // embedded Cap.js
162
+ res = await capChallenge()
163
+ break
164
+ case 'CAP_REDEEM':
165
+ res = await capRedeem(event)
166
+ break
154
167
  default:
155
168
  if (event.event) {
156
169
  res.code = RES_CODE.EVENT_NOT_EXIST
@@ -760,6 +773,14 @@ async function checkCaptcha (comment) {
760
773
  geeTestPassToken: comment.geeTestPassToken,
761
774
  geeTestGenTime: comment.geeTestGenTime
762
775
  })
776
+ } else if (provider === 'Cap' && isBuiltinCap(config)) {
777
+ if (!comment.capToken) {
778
+ throw new Error('验证码 token 缺失,请刷新页面重试')
779
+ }
780
+ await checkCapCaptcha({
781
+ capToken: comment.capToken,
782
+ cap: createCap(tcbStorage(db))
783
+ })
763
784
  } else if (provider === 'Cap' && config.CAP_API_ENDPOINT && config.CAP_SECRET_KEY) {
764
785
  if (!comment.capToken) {
765
786
  throw new Error('验证码 token 缺失,请刷新页面重试')
@@ -770,7 +791,7 @@ async function checkCaptcha (comment) {
770
791
  capApiEndpoint: config.CAP_API_ENDPOINT
771
792
  })
772
793
  } else if (provider === 'Cap') {
773
- throw new Error('Cap 验证码配置不完整,请联系管理员')
794
+ throw new Error('Cap 验证码配置不完整:内嵌模式无需额外配置,外部模式需填写 CAP_API_ENDPOINT 与 CAP_SECRET_KEY')
774
795
  } else if (provider) {
775
796
  throw new Error(`不支持的验证码类型: ${provider}`)
776
797
  }
@@ -1027,9 +1048,27 @@ function isRecursion (context) {
1027
1048
  return envObj.TCB_SOURCE.substr(-3, 3) === 'scf'
1028
1049
  }
1029
1050
 
1051
+ async function capChallenge () {
1052
+ if (!isBuiltinCap(config)) {
1053
+ return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
1054
+ }
1055
+ const cap = createCap(tcbStorage(db))
1056
+ const data = await createChallenge(cap)
1057
+ return { code: RES_CODE.SUCCESS, ...data }
1058
+ }
1059
+
1060
+ async function capRedeem (event) {
1061
+ if (!isBuiltinCap(config)) {
1062
+ return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
1063
+ }
1064
+ const cap = createCap(tcbStorage(db))
1065
+ const data = await redeemChallenge(cap, event)
1066
+ return { code: RES_CODE.SUCCESS, ...data }
1067
+ }
1068
+
1030
1069
  // 建立数据库 collections
1031
1070
  async function createCollections () {
1032
- const collections = ['comment', 'config', 'counter']
1071
+ const collections = ['comment', 'config', 'counter', 'cap_challenges', 'cap_tokens']
1033
1072
  const res = {}
1034
1073
  for (const collection of collections) {
1035
1074
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twikoo-func",
3
- "version": "1.7.14",
3
+ "version": "1.7.16",
4
4
  "description": "A simple comment system.",
5
5
  "author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
6
6
  "license": "MIT",
@@ -11,6 +11,10 @@
11
11
  },
12
12
  "homepage": "https://twikoo.js.org",
13
13
  "dependencies": {
14
+ "@xsai/generate-text": "0.2.2",
15
+ "@xsai/shared": "0.2.2",
16
+ "@xsai/shared-chat": "0.2.2",
17
+ "@cap.js/server": "^4.0.5",
14
18
  "@cloudbase/manager-node": "^3.9.0",
15
19
  "@cloudbase/node-sdk": "^2.5.0",
16
20
  "@imaegoo/node-ip2region": "^2.1.1",
@@ -20,12 +24,11 @@
20
24
  "bowser": "^2.11.0",
21
25
  "cheerio": "1.0.0-rc.5",
22
26
  "crypto-js": "^4.0.0",
23
- "dompurify": "^2.2.6",
27
+ "dompurify": "^2.5.9",
24
28
  "form-data": "^4.0.0",
25
29
  "jsdom": "^16.4.0",
26
30
  "marked": "^4.0.12",
27
- "nodemailer": "^7.0.11",
28
- "openai": "^6.45.0",
31
+ "nodemailer": "^9.0.5",
29
32
  "pushoo": "latest",
30
33
  "tencentcloud-sdk-nodejs": "^4.0.65",
31
34
  "xml2js": "^0.6.0"
package/utils/cap.js ADDED
@@ -0,0 +1,311 @@
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/image.js CHANGED
@@ -1,6 +1,4 @@
1
- const fs = require('fs')
2
- const os = require('os')
3
- const path = require('path')
1
+ const crypto = require('crypto')
4
2
  const { isUrl } = require('.')
5
3
  const { RES_CODE } = require('./constants')
6
4
  const { getAxios, getFormData } = require('./lib')
@@ -8,9 +6,33 @@ const axios = getAxios()
8
6
  const FormData = getFormData()
9
7
  const logger = require('./logger')
10
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
+
11
33
  const fn = {
12
34
  async uploadImage (event, config) {
13
- const { photo, fileName } = event
35
+ const { photo } = event
14
36
  const res = {}
15
37
  const imageService = config.IMAGE_CDN
16
38
  try {
@@ -22,8 +44,9 @@ const fn = {
22
44
  } else if (!imageService || !config.IMAGE_CDN_TOKEN) {
23
45
  throw new Error('未配置图片上传服务')
24
46
  }
47
+ const image = fn.parseImage(photo)
25
48
  if (config.NSFW_API_URL) {
26
- const nsfwResult = await fn.checkNsfw({ photo, config })
49
+ const nsfwResult = await fn.checkNsfw({ image, config })
27
50
  if (nsfwResult.rejected) {
28
51
  res.code = RES_CODE.NSFW_REJECTED
29
52
  res.err = nsfwResult.message
@@ -32,21 +55,21 @@ const fn = {
32
55
  }
33
56
  // tip: qcloud 图床走前端上传,其他图床走后端上传
34
57
  if (imageService === '7bu') {
35
- await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: 'https://7bu.top' })
58
+ await fn.uploadImageToLskyPro({ image, config, res, imageCdn: 'https://7bu.top' })
36
59
  } else if (imageService === 'see') {
37
- await fn.uploadImageToSee({ photo, fileName, config, res, imageCdn: 'https://s.ee/api/v1/file/upload' })
60
+ await fn.uploadImageToSee({ image, config, res, imageCdn: 'https://s.ee/api/v1/file/upload' })
38
61
  } else if (isUrl(imageService)) {
39
- await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: imageService })
62
+ await fn.uploadImageToLskyPro({ image, config, res, imageCdn: imageService })
40
63
  } else if (imageService === 'lskypro') {
41
- await fn.uploadImageToLskyPro({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN_URL })
64
+ await fn.uploadImageToLskyPro({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
42
65
  } else if (imageService === 'piclist') {
43
- await fn.uploadImageToPicList({ photo, fileName, config, res, imageCdn: config.IMAGE_CDN_URL })
66
+ await fn.uploadImageToPicList({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
44
67
  } else if (imageService === 'easyimage') {
45
- await fn.uploadImageToEasyImage({ photo, fileName, config, res })
68
+ await fn.uploadImageToEasyImage({ image, config, res })
46
69
  } else if (imageService === 'chevereto') {
47
- await fn.uploadImageToChevereto({ photo, fileName, config, res })
70
+ await fn.uploadImageToChevereto({ image, config, res })
48
71
  } else if (imageService === 's3') {
49
- await fn.uploadImageToS3({ photo, fileName, config, res })
72
+ await fn.uploadImageToS3({ image, config, res })
50
73
  } else {
51
74
  throw new Error('不支持的图片上传服务')
52
75
  }
@@ -57,13 +80,13 @@ const fn = {
57
80
  }
58
81
  return res
59
82
  },
60
- async checkNsfw ({ photo, config }) {
83
+ async checkNsfw ({ image, config }) {
61
84
  const result = { rejected: false, message: '' }
62
85
  try {
63
86
  const threshold = parseFloat(config.NSFW_THRESHOLD) || 0.5
64
87
  const apiUrl = config.NSFW_API_URL.replace(/\/$/, '')
65
88
  const formData = new FormData()
66
- formData.append('image', fn.base64UrlToReadStream(photo, 'nsfw_check.jpg'))
89
+ fn.appendImage(formData, 'image', image)
67
90
  const response = await axios.post(`${apiUrl}/classify`, formData, {
68
91
  headers: {
69
92
  ...formData.getHeaders()
@@ -84,10 +107,10 @@ const fn = {
84
107
  }
85
108
  return result
86
109
  },
87
- async uploadImageToSee ({ photo, fileName, config, res, imageCdn }) {
110
+ async uploadImageToSee ({ image, config, res, imageCdn }) {
88
111
  // S.EE 图床 https://s.ee (原 SM.MS)
89
112
  const formData = new FormData()
90
- formData.append('smfile', fn.base64UrlToReadStream(photo, fileName))
113
+ fn.appendImage(formData, 'smfile', image)
91
114
  const uploadResult = await axios.post(imageCdn, formData, {
92
115
  headers: {
93
116
  ...formData.getHeaders(),
@@ -100,10 +123,10 @@ const fn = {
100
123
  throw new Error(uploadResult.data.message)
101
124
  }
102
125
  },
103
- async uploadImageToLskyPro ({ photo, fileName, config, res, imageCdn }) {
126
+ async uploadImageToLskyPro ({ image, config, res, imageCdn }) {
104
127
  // 自定义兰空图床(v2)URL
105
128
  const formData = new FormData()
106
- formData.append('file', fn.base64UrlToReadStream(photo, fileName))
129
+ fn.appendImage(formData, 'file', image)
107
130
  if (process.env.TWIKOO_LSKY_STRATEGY_ID) {
108
131
  formData.append('strategy_id', parseInt(process.env.TWIKOO_LSKY_STRATEGY_ID))
109
132
  }
@@ -125,11 +148,11 @@ const fn = {
125
148
  throw new Error(uploadResult.data.message)
126
149
  }
127
150
  },
128
- async uploadImageToPicList ({ photo, fileName, config, res, imageCdn }) {
151
+ async uploadImageToPicList ({ image, config, res, imageCdn }) {
129
152
  // PicList https://piclist.cn/ 高效的云存储和图床平台管理工具
130
153
  // 鉴权使用 query 参数 key
131
154
  const formData = new FormData()
132
- formData.append('file', fn.base64UrlToReadStream(photo, fileName))
155
+ fn.appendImage(formData, 'file', image)
133
156
  let url = `${imageCdn}/upload`
134
157
  // 如果填写了 key 则拼接 url
135
158
  if (config.IMAGE_CDN_TOKEN) {
@@ -143,7 +166,7 @@ const fn = {
143
166
  throw new Error(uploadResult.data.message)
144
167
  }
145
168
  },
146
- async uploadImageToEasyImage ({ photo, fileName, config, res }) {
169
+ async uploadImageToEasyImage ({ image, config, res }) {
147
170
  // EasyImage2.0 https://github.com/icret/EasyImages2.0 简单图床 - 一款功能强大无数据库的图床 2.0版
148
171
  try {
149
172
  // 参数校验
@@ -158,9 +181,7 @@ const fn = {
158
181
  // 添加 token 参数到 Body
159
182
  formData.append('token', config.IMAGE_CDN_TOKEN)
160
183
  // 添加图片文件(固定参数名 image)
161
- formData.append('image', fn.base64UrlToReadStream(photo, fileName), {
162
- filename: fileName
163
- })
184
+ fn.appendImage(formData, 'image', image)
164
185
  // 发送请求
165
186
  const uploadResult = await axios.post(config.IMAGE_CDN_URL, formData, {
166
187
  headers: {
@@ -193,7 +214,7 @@ const fn = {
193
214
  throw new Error(errorMsg)
194
215
  }
195
216
  },
196
- async uploadImageToChevereto ({ photo, fileName, config, res }) {
217
+ async uploadImageToChevereto ({ image, config, res }) {
197
218
  if (!config.IMAGE_CDN_URL) {
198
219
  throw new Error('未配置 Chevereto 站点地址 (IMAGE_CDN_URL)')
199
220
  }
@@ -202,7 +223,7 @@ const fn = {
202
223
  }
203
224
  const formData = new FormData()
204
225
  formData.append('key', config.IMAGE_CDN_TOKEN)
205
- formData.append('source', fn.base64UrlToReadStream(photo, fileName))
226
+ fn.appendImage(formData, 'source', image)
206
227
  formData.append('format', 'json')
207
228
  const apiUrl = config.IMAGE_CDN_URL.replace(/\/$/, '') + '/api/1/upload'
208
229
  const uploadResult = await axios.post(apiUrl, formData, {
@@ -222,7 +243,7 @@ const fn = {
222
243
  throw new Error(`Chevereto 上传失败: ${errMsg}`)
223
244
  }
224
245
  },
225
- async uploadImageToS3 ({ photo, fileName, config, res }) {
246
+ async uploadImageToS3 ({ image, config, res }) {
226
247
  // 使用原生 crypto + axios 实现 AWS Signature V4,无需引入 SDK
227
248
  if (!config.S3_BUCKET) {
228
249
  throw new Error('未配置 S3 存储桶名称 (S3_BUCKET)')
@@ -233,15 +254,11 @@ const fn = {
233
254
  if (!config.S3_SECRET_ACCESS_KEY) {
234
255
  throw new Error('未配置 S3 Secret Access Key (S3_SECRET_ACCESS_KEY)')
235
256
  }
236
- const crypto = require('crypto')
237
257
  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')
258
+ const { body, mimeType, fileName } = image
242
259
  // 构建对象 key
243
260
  const prefix = config.S3_PATH_PREFIX ? config.S3_PATH_PREFIX.replace(/\/$/, '') + '/' : ''
244
- const key = `${prefix}${Date.now()}-${fileName}`
261
+ const key = `${prefix}${fileName}`
245
262
  const forcePathStyle = String(config.S3_FORCE_PATH_STYLE).trim().toLowerCase() !== 'false'
246
263
  let endpoint
247
264
  let s3Base
@@ -315,11 +332,44 @@ const fn = {
315
332
  }
316
333
  res.data = { url: fileUrl }
317
334
  },
318
- base64UrlToReadStream (base64Url, fileName) {
319
- const base64 = base64Url.split(';base64,').pop()
320
- const writePath = path.resolve(os.tmpdir(), fileName)
321
- fs.writeFileSync(writePath, base64, { encoding: 'base64' })
322
- return fs.createReadStream(writePath)
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
+ })
323
373
  }
324
374
  }
325
375
 
package/utils/index.js CHANGED
@@ -268,7 +268,7 @@ const fn = {
268
268
  if (qqApiKey) {
269
269
  headers.Authorization = `Bearer ${qqApiKey}`
270
270
  }
271
- const result = await axios.get(`https://v1.nsuuu.com/api/qqname?qq=${qqNum}`, { headers })
271
+ const result = await axios.get(`https://v1.tqq.me/v1/qqname?qq=${qqNum}`, { headers })
272
272
  if (result.data?.code === 200 && result.data?.data?.nick) {
273
273
  return result.data.data.nick
274
274
  }
@@ -366,11 +366,17 @@ const fn = {
366
366
  throw new Error('极验验证码检测失败: ' + e.message)
367
367
  }
368
368
  },
369
- async checkCapCaptcha ({ capToken, capSecretKey, capApiEndpoint }) {
369
+ async checkCapCaptcha ({ capToken, capSecretKey, capApiEndpoint, cap }) {
370
370
  try {
371
- // 移除末尾的斜杠,避免双斜杠
371
+ // 内嵌 Cap:直接 validateToken,无需外部 Standalone
372
+ if (cap) {
373
+ const { validateToken } = require('./cap')
374
+ const ok = await validateToken(cap, capToken)
375
+ if (!ok) throw new Error('验证码错误')
376
+ return
377
+ }
378
+ // 外部 Cap Standalone:HTTP siteverify
372
379
  const endpoint = capApiEndpoint.replace(/\/$/, '')
373
- // Cap 的 siteverify 端点
374
380
  const url = `${endpoint}/siteverify`
375
381
  logger.log('Cap验证码验证URL:', url)
376
382
  logger.log('Cap验证码验证参数:', { secret: capSecretKey ? '***' : undefined, response: capToken.substring(0, 20) + '...' })
@@ -412,8 +418,7 @@ const fn = {
412
418
  HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
413
419
  HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
414
420
  LIMIT_LENGTH: config.LIMIT_LENGTH,
415
- CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER,
416
- QQ_API_KEY: config.QQ_API_KEY
421
+ CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER
417
422
  }
418
423
 
419
424
  // 仅在明确指定使用 Turnstile 时下发 Turnstile 的 site key
@@ -426,9 +431,13 @@ const fn = {
426
431
  baseConfig.GEETEST_CAPTCHA_ID = config.GEETEST_CAPTCHA_ID
427
432
  }
428
433
 
429
- // 仅在明确指定使用 Cap 时下发 Cap api endpoint
434
+ // Cap:有外部 endpoint 则下发;否则标记 builtin,前端走 twikoo 事件代理
430
435
  if (config.CAPTCHA_PROVIDER === 'Cap') {
431
- baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
436
+ if (config.CAP_API_ENDPOINT) {
437
+ baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
438
+ } else {
439
+ baseConfig.CAP_BUILTIN = true
440
+ }
432
441
  }
433
442
 
434
443
  return {
package/utils/lib.js CHANGED
@@ -71,13 +71,5 @@ module.exports = {
71
71
  getXml2js () {
72
72
  const xml2js = require('xml2js') // XML 解析
73
73
  return xml2js
74
- },
75
- getOpenAIClient (config) {
76
- const OpenAI = require('openai') // OpenAI 的 SDK,用于反垃圾
77
- const openaiClient = new OpenAI({
78
- apiKey: config.LLM_API_KEY,
79
- baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com'
80
- })
81
- return openaiClient
82
74
  }
83
75
  }
package/utils/notify.js CHANGED
@@ -9,6 +9,18 @@ const pushoo = getPushoo()
9
9
  const { RES_CODE } = require('./constants')
10
10
  const logger = require('./logger')
11
11
 
12
+ // HTML 实体转义,防止用户可控字段(昵称、邮箱等)在邮件 HTML 模板中造成存储型 XSS
13
+ // 仅在渲染邮件时转义,不修改数据库的存储内容
14
+ function escapeHtml (str) {
15
+ if (typeof str !== 'string') return str
16
+ return str
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ .replace(/'/g, '&#39;')
22
+ }
23
+
12
24
  let nodemailer
13
25
 
14
26
  function lazilyGetNodemailer () {
@@ -85,13 +97,13 @@ const fn = {
85
97
  return
86
98
  }
87
99
  const SITE_NAME = config.SITE_NAME
88
- const NICK = comment.nick
100
+ const NICK = escapeHtml(comment.nick)
89
101
  const IMG = getAvatar(comment, config)
90
102
  const IP = comment.ip
91
- const MAIL = comment.mail
103
+ const MAIL = escapeHtml(comment.mail)
92
104
  const COMMENT = comment.comment
93
105
  const SITE_URL = config.SITE_URL
94
- const POST_URL = fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
106
+ const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
95
107
  const emailSubject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}上有新评论了`
96
108
  let emailContent
97
109
  if (config.MAIL_TEMPLATE_ADMIN) {
@@ -160,12 +172,12 @@ const fn = {
160
172
  // 即时消息推送内容获取
161
173
  getIMPushContent (comment, config) {
162
174
  const SITE_NAME = config.SITE_NAME
163
- const NICK = comment.nick
164
- const MAIL = comment.mail
175
+ const NICK = escapeHtml(comment.nick)
176
+ const MAIL = escapeHtml(comment.mail)
165
177
  const IP = comment.ip
166
178
  const COMMENT = $(comment.comment).text()
167
179
  const SITE_URL = config.SITE_URL
168
- const POST_URL = fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
180
+ const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
169
181
  const subject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}有新评论了`
170
182
  const content = `评论人:${NICK} ([${MAIL}](mailto:${MAIL}))
171
183
 
@@ -199,14 +211,14 @@ const fn = {
199
211
  logger.info('回复自己的评论,不邮件通知')
200
212
  return
201
213
  }
202
- const PARENT_NICK = parentComment.nick
214
+ const PARENT_NICK = escapeHtml(parentComment.nick)
203
215
  const IMG = getAvatar(currentComment, config)
204
216
  const PARENT_IMG = getAvatar(parentComment, config)
205
217
  const SITE_NAME = config.SITE_NAME
206
- const NICK = currentComment.nick
218
+ const NICK = escapeHtml(currentComment.nick)
207
219
  const COMMENT = currentComment.comment
208
220
  const PARENT_COMMENT = parentComment.comment
209
- const POST_URL = fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id)
221
+ const POST_URL = escapeHtml(fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id))
210
222
  const SITE_URL = config.SITE_URL
211
223
  const emailSubject = config.MAIL_SUBJECT || `${PARENT_NICK},您在『${SITE_NAME}』上的评论收到了回复`
212
224
  let emailContent
package/utils/spam.js CHANGED
@@ -1,8 +1,7 @@
1
1
  const {
2
2
  getAkismetClient,
3
3
  getCryptoJS,
4
- getTencentcloud,
5
- getOpenAIClient
4
+ getTencentcloud
6
5
  } = require('./lib')
7
6
  const {
8
7
  equalsMail
@@ -13,9 +12,7 @@ const CryptoJS = getCryptoJS()
13
12
  const logger = require('./logger')
14
13
 
15
14
  let tencentcloud
16
- let openai
17
- let _openaiApiKey
18
- let _openaiEndpoint
15
+ let generateTextPromise
19
16
 
20
17
  function getTencentCloud () {
21
18
  if (!tencentcloud) {
@@ -28,19 +25,16 @@ function getTencentCloud () {
28
25
  return tencentcloud
29
26
  }
30
27
 
31
- function getOpenAI (config) {
32
- if (isConfigChanged(config)) {
33
- _openaiApiKey = config.LLM_API_KEY || ''
34
- _openaiEndpoint = config.LLM_API_ENDPOINT || ''
35
- openai = getOpenAIClient(config)
28
+ function getGenerateText () {
29
+ if (!generateTextPromise) {
30
+ generateTextPromise = import('@xsai/generate-text')
31
+ .then(({ generateText }) => generateText)
32
+ .catch((error) => {
33
+ generateTextPromise = null
34
+ throw error
35
+ })
36
36
  }
37
- return openai
38
- }
39
-
40
- function isConfigChanged (config) {
41
- return !openai ||
42
- _openaiApiKey !== (config.LLM_API_KEY || '') ||
43
- _openaiEndpoint !== (config.LLM_API_ENDPOINT || '')
37
+ return generateTextPromise
44
38
  }
45
39
 
46
40
  // 提取json结构的函数
@@ -126,8 +120,6 @@ async function checkByLLM (comment, config) {
126
120
  const maxRetries = Number(config.LLM_MAX_RETRIES) || 3
127
121
  let lastError = ''
128
122
 
129
- const openai = getOpenAI(config)
130
-
131
123
  // 网络/Provider 异常或者格式校验不通过会进入重试逻辑
132
124
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
133
125
  if (attempt > 1) {
@@ -142,13 +134,16 @@ async function checkByLLM (comment, config) {
142
134
  messages = buildMessages(comment, lastError)
143
135
  }
144
136
 
145
- const chatCompletion = await openai.chat.completions.create({
146
- model: config.LLM_MODEL || 'deepseek-v4-pro',
147
- response_format: { type: 'json_object' },
137
+ const generateText = await getGenerateText()
138
+ const chatCompletion = await generateText({
139
+ apiKey: config.LLM_API_KEY,
140
+ baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1',
141
+ model: config.LLM_MODEL || 'deepseek-chat',
142
+ responseFormat: { type: 'json_object' },
148
143
  messages
149
144
  })
150
145
 
151
- const rawText = chatCompletion.choices[0].message.content || ''
146
+ const rawText = chatCompletion.text || ''
152
147
 
153
148
  const extracted = extractJson(rawText)
154
149
  const repaired = repairJson(extracted)