twikoo-func 1.7.15 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twikoo-func",
3
- "version": "1.7.15",
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,9 @@
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",
14
17
  "@cap.js/server": "^4.0.5",
15
18
  "@cloudbase/manager-node": "^3.9.0",
16
19
  "@cloudbase/node-sdk": "^2.5.0",
@@ -21,14 +24,13 @@
21
24
  "bowser": "^2.11.0",
22
25
  "cheerio": "1.0.0-rc.5",
23
26
  "crypto-js": "^4.0.0",
24
- "dompurify": "^2.2.6",
27
+ "dompurify": "^2.5.9",
25
28
  "form-data": "^4.0.0",
26
29
  "jsdom": "^16.4.0",
27
30
  "marked": "^4.0.12",
28
- "nodemailer": "^7.0.11",
31
+ "nodemailer": "^9.0.5",
29
32
  "pushoo": "latest",
30
33
  "tencentcloud-sdk-nodejs": "^4.0.65",
31
- "xml2js": "^0.6.0",
32
- "xsai": "^0.2.2"
34
+ "xml2js": "^0.6.0"
33
35
  }
34
36
  }
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
@@ -418,8 +418,7 @@ const fn = {
418
418
  HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
419
419
  HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
420
420
  LIMIT_LENGTH: config.LIMIT_LENGTH,
421
- CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER,
422
- QQ_API_KEY: config.QQ_API_KEY
421
+ CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER
423
422
  }
424
423
 
425
424
  // 仅在明确指定使用 Turnstile 时下发 Turnstile 的 site key
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 { createXSClient } = require('xsai') // xsai SDK
77
- const openaiClient = createXSClient({
78
- apiKey: config.LLM_API_KEY,
79
- baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1'
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
@@ -12,6 +12,7 @@ const CryptoJS = getCryptoJS()
12
12
  const logger = require('./logger')
13
13
 
14
14
  let tencentcloud
15
+ let generateTextPromise
15
16
 
16
17
  function getTencentCloud () {
17
18
  if (!tencentcloud) {
@@ -24,6 +25,18 @@ function getTencentCloud () {
24
25
  return tencentcloud
25
26
  }
26
27
 
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
+ }
37
+ return generateTextPromise
38
+ }
39
+
27
40
  // 提取json结构的函数
28
41
  function extractJson (rawText) {
29
42
  if (!rawText) return ''
@@ -121,8 +134,10 @@ async function checkByLLM (comment, config) {
121
134
  messages = buildMessages(comment, lastError)
122
135
  }
123
136
 
124
- const { generateText } = require('xsai')
137
+ const generateText = await getGenerateText()
125
138
  const chatCompletion = await generateText({
139
+ apiKey: config.LLM_API_KEY,
140
+ baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1',
126
141
  model: config.LLM_MODEL || 'deepseek-chat',
127
142
  responseFormat: { type: 'json_object' },
128
143
  messages