koishi-plugin-chat-patch 5.6.0 → 6.0.0

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/src/web.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Context } from 'koishi'
2
2
  import {} from '@koishijs/plugin-server'
3
- import { createReadStream, existsSync, promises as fs, statSync } from 'node:fs'
3
+ import { createReadStream, createWriteStream, existsSync, promises as fs, statSync } from 'node:fs'
4
4
  import { createHash } from 'node:crypto'
5
5
  import path from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
- import send from 'koa-send'
8
- import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
7
+ import send from 'koa-send'
8
+ import type Router from '@koa/router'
9
+ import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
9
10
  import FileType from 'file-type'
10
11
 
11
12
  import { Config } from './config'
@@ -13,7 +14,7 @@ import { ContactCacheService } from './cache'
13
14
  import { ChatDatabase } from './database'
14
15
  import { MediaManager } from './media'
15
16
  import { PluginLogger } from './logger'
16
- import { ContactCacheItem, SelfMessagePayload, SelfMessageRecord } from './types'
17
+ import { ContactCacheItem, MessageRecord, SelfMessagePayload, SelfMessageRecord } from './types'
17
18
 
18
19
  interface ViteConsoleServer {
19
20
  config?: {
@@ -42,7 +43,7 @@ export function registerWeb(
42
43
  const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
43
44
  const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
44
45
 
45
- type WebContext = ParameterizedContext<DefaultState, DefaultContext>
46
+ type WebContext = Router.RouterContext<DefaultState, DefaultContext>
46
47
  const cacheType = (type: string) => type === 'user' ? 'friend' : type
47
48
 
48
49
  const getVite = (): ViteConsoleServer | undefined => {
@@ -133,16 +134,21 @@ export function registerWeb(
133
134
  }
134
135
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
135
136
 
136
- ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
137
- const requestBody = (koa.request as unknown as { body?: unknown }).body
138
- const body = (requestBody ?? {}) as Record<string, unknown>
139
- let source = String(body.dataUrl ?? body.data ?? '')
140
- let name = String(body.name ?? '')
141
- let mime = ''
142
- let buffer: Buffer | null = null
143
- if (source) {
144
- if (source.startsWith('base64://')) source = source.slice(9)
145
- const comma = source.indexOf('base64,')
137
+ ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
138
+ const requestBody = (koa.request as unknown as { body?: unknown }).body
139
+ const body = (requestBody ?? {}) as Record<string, unknown>
140
+ let source = String(body.dataUrl ?? body.data ?? '')
141
+ let name = String(body.name ?? '')
142
+ let mime = ''
143
+ let buffer: Buffer | null = null
144
+ let tempPath = ''
145
+ const rawHash = createHash('md5')
146
+ const cleanupTemp = async () => {
147
+ if (tempPath) await fs.unlink(tempPath).catch(() => undefined)
148
+ }
149
+ if (source) {
150
+ if (source.startsWith('base64://')) source = source.slice(9)
151
+ const comma = source.indexOf('base64,')
146
152
  const base64 = comma >= 0 ? source.slice(comma + 7) : source
147
153
  const normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
148
154
  try {
@@ -152,60 +158,86 @@ export function registerWeb(
152
158
  koa.body = { error: 'invalid media data' }
153
159
  return
154
160
  }
155
- mime = source.startsWith('data:')
156
- ? source.slice(5, source.indexOf(';')).toLowerCase()
157
- : ''
158
- } else {
159
- const chunks: Buffer[] = []
160
- for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
161
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
162
- }
163
- buffer = Buffer.concat(chunks)
164
- name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
165
- try {
166
- name = decodeURIComponent(name)
161
+ mime = source.startsWith('data:')
162
+ ? source.slice(5, source.indexOf(';')).toLowerCase()
163
+ : ''
164
+ } else {
165
+ tempPath = path.join(uploadDir, `.upload-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`)
166
+ const write = createWriteStream(tempPath)
167
+ try {
168
+ for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
169
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
170
+ rawHash.update(part)
171
+ if (!write.write(part)) {
172
+ await new Promise<void>((resolve) => write.once('drain', resolve))
173
+ }
174
+ }
175
+ await new Promise<void>((resolve, reject) => {
176
+ write.once('error', reject)
177
+ write.end(() => resolve())
178
+ })
179
+ } catch (error) {
180
+ write.destroy()
181
+ await cleanupTemp()
182
+ throw error
183
+ }
184
+ name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
185
+ try {
186
+ name = decodeURIComponent(name)
167
187
  } catch {
168
188
  // 保持原始名称即可
169
189
  }
170
- mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
171
- }
172
- if (!buffer || !buffer.length) {
173
- koa.status = 400
174
- koa.body = { error: 'missing media data' }
175
- return
176
- }
177
- if (buffer.length > MAX_UPLOAD_BYTES) {
178
- koa.status = 413
179
- koa.body = { error: 'media file too large' }
180
- return
181
- }
182
- if (!buffer.length) {
183
- koa.status = 400
184
- koa.body = { error: 'invalid media data' }
185
- return
186
- }
187
-
188
- await fs.mkdir(uploadDir, { recursive: true })
189
- let detected: { ext?: string } | undefined
190
- try {
191
- const result = await FileType.fromBuffer(buffer)
192
- detected = result ?? undefined
193
- } catch {
194
- // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
195
- detected = undefined
190
+ mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
191
+ }
192
+ const fileSize = buffer ? buffer.length : existsSync(tempPath) ? statSync(tempPath).size : 0
193
+ if (!fileSize) {
194
+ await cleanupTemp()
195
+ koa.status = 400
196
+ koa.body = { error: 'missing media data' }
197
+ return
198
+ }
199
+ if (fileSize > MAX_UPLOAD_BYTES) {
200
+ await cleanupTemp()
201
+ koa.status = 413
202
+ koa.body = { error: 'media file too large' }
203
+ return
204
+ }
205
+
206
+ await fs.mkdir(uploadDir, { recursive: true })
207
+ let detected: { ext?: string } | undefined
208
+ try {
209
+ const result = buffer
210
+ ? await FileType.fromBuffer(buffer)
211
+ : await FileType.fromStream(createReadStream(tempPath))
212
+ detected = result ?? undefined
213
+ } catch {
214
+ // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
215
+ detected = undefined
196
216
  }
197
217
  const mimeExt = mimeToExt[mime] || ''
198
218
  const nameExt = path.extname(name).toLowerCase()
199
- const ext = detected?.ext
200
- ? toExtension(detected.ext)
201
- : mimeExt || nameExt || '.bin'
202
- const filename = `${createHash('md5').update(buffer).digest('hex')}${ext}`
203
- const filePath = path.join(uploadDir, filename)
204
- if (!existsSync(filePath)) {
205
- await fs.writeFile(filePath, buffer)
206
- }
207
- koa.body = {
208
- path: pathToFileURL(filePath).href,
219
+ const ext = detected?.ext
220
+ ? toExtension(detected.ext)
221
+ : mimeExt || nameExt || '.bin'
222
+ const digest = buffer ? createHash('md5').update(buffer).digest('hex') : rawHash.digest('hex')
223
+ const filename = `${digest}${ext}`
224
+ const filePath = path.join(uploadDir, filename)
225
+ try {
226
+ if (!existsSync(filePath)) {
227
+ if (buffer) {
228
+ await fs.writeFile(filePath, buffer)
229
+ } else {
230
+ await fs.rename(tempPath, filePath)
231
+ tempPath = ''
232
+ }
233
+ }
234
+ } catch (error) {
235
+ await cleanupTemp()
236
+ throw error
237
+ }
238
+ await cleanupTemp()
239
+ koa.body = {
240
+ path: pathToFileURL(filePath).href,
209
241
  localPath: filePath,
210
242
  }
211
243
  })
@@ -303,8 +335,12 @@ export function registerWeb(
303
335
  koa.body = { error: 'missing history params' }
304
336
  return
305
337
  }
306
- const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel)
307
- const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel
338
+ const defaultLimit = Math.min(config.maxMessagesPerChannel, 100)
339
+ const parsedLimit = Number(koa.query.limit ?? defaultLimit)
340
+ const limit = Math.min(
341
+ Math.max(1, Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.floor(parsedLimit) : defaultLimit),
342
+ config.maxMessagesPerChannel,
343
+ )
308
344
  const beforeTime = Number(koa.query.beforeTime ?? 0)
309
345
  const queryMessages = async (cid: string) => {
310
346
  return Number.isFinite(beforeTime) && beforeTime > 0
@@ -331,10 +367,35 @@ export function registerWeb(
331
367
  const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
332
368
  const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
333
369
  return timeA - timeB
334
- })
335
- selfMessages.sort((a, b) => a.sentAt - b.sentAt)
336
- koa.body = { messages, selfMessages }
337
- })
370
+ })
371
+ selfMessages.sort((a, b) => a.sentAt - b.sentAt)
372
+ // 合并两条记录流后只返回 limit 条,保证前端分页/结束判断不会因自消息被放大
373
+ const combined: Array<{
374
+ kind: 'message' | 'self'
375
+ time: number
376
+ record: MessageRecord | SelfMessageRecord
377
+ }> = []
378
+ for (const item of messages) {
379
+ combined.push({
380
+ kind: 'message',
381
+ time: Number(item?.receivedAt ?? item?.timestampMs ?? item?.timestamp ?? 0),
382
+ record: item,
383
+ })
384
+ }
385
+ for (const item of selfMessages) {
386
+ combined.push({ kind: 'self', time: item.sentAt, record: item })
387
+ }
388
+ combined.sort((a, b) => a.time - b.time)
389
+ const capped = combined.slice(-limit)
390
+ koa.body = {
391
+ messages: capped
392
+ .filter((item) => item.kind === 'message')
393
+ .map((item) => item.record as MessageRecord),
394
+ selfMessages: capped
395
+ .filter((item) => item.kind === 'self')
396
+ .map((item) => item.record as SelfMessageRecord),
397
+ }
398
+ })
338
399
 
339
400
  ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
340
401
  const platform = String(koa.query.platform ?? '')
@@ -685,15 +746,20 @@ export function registerWeb(
685
746
  })
686
747
 
687
748
  const serveFile = async (koa: WebContext, fileName: string) => {
688
- const fullPath = path.join(webRoot, fileName)
689
- if (existsSync(fullPath) && statSync(fullPath).isFile()) {
690
- await send(koa, path.relative(webRoot, fullPath), { root: webRoot })
691
- return
692
- }
693
- koa.status = 200
694
- koa.body = ''
695
- await send(koa, 'index.html', { root: webRoot })
696
- }
749
+ const fullPath = path.join(webRoot, fileName)
750
+ if (existsSync(fullPath) && statSync(fullPath).isFile()) {
751
+ const rootContext = koa as unknown as ParameterizedContext<DefaultState, DefaultContext>
752
+ await send(rootContext, path.relative(webRoot, fullPath), { root: webRoot })
753
+ return
754
+ }
755
+ koa.status = 200
756
+ koa.body = ''
757
+ await send(
758
+ koa as unknown as ParameterizedContext<DefaultState, DefaultContext>,
759
+ 'index.html',
760
+ { root: webRoot },
761
+ )
762
+ }
697
763
 
698
764
  const serveDevWeb = async (koa: WebContext): Promise<boolean> => {
699
765
  const vite = getVite()