From eb6c2dc9f68cb1ee864183c54aea644e47b12f88 Mon Sep 17 00:00:00 2001 From: ekko Date: Mon, 20 Apr 2026 12:15:47 +0800 Subject: [PATCH] fix: resolve Chinese filename garbling on upload and page update failure (#72, #71) - Fix multipart upload parsing to use Buffer operations instead of latin1 string conversion, preserving multi-byte characters in filenames (#72) - Support RFC 5987 filename* encoding for cross-platform compatibility - Fix in-page update by running npm install directly instead of CLI command that kills the server process before response is sent (#71) - Add no-cache header to version check to avoid stale latest version - Change version check interval from 4 hours to 1 hour Closes #72, Closes #71 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- packages/server/src/index.ts | 11 +++++-- packages/server/src/routes/upload.ts | 49 ++++++++++++++++++++++------ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 9be3eb2..77bf56c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.3.7", + "version": "0.3.8", "description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)", "repository": { "type": "git", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 285a96b..d883d57 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -36,6 +36,7 @@ async function checkLatestVersion(): Promise { try { const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', { signal: AbortSignal.timeout(5000), + headers: { 'Cache-Control': 'no-cache' }, }) if (res.ok) { const data = await res.json() @@ -83,9 +84,11 @@ export async function bootstrap() { app.use(async (ctx, next) => { if (ctx.path === '/api/hermes/update' && ctx.method === 'POST') { const isWin = process.platform === 'win32' + // Run npm install directly — calling `hermes-web-ui update` would kill this + // process (stopDaemon) before the response can be sent to the client. const cmd = isWin - ? 'cmd /c hermes-web-ui update' - : 'hermes-web-ui update' + ? 'cmd /c npm install -g hermes-web-ui@latest' + : 'npm install -g hermes-web-ui@latest' try { const { execSync } = await import('child_process') @@ -95,6 +98,8 @@ export async function bootstrap() { stdio: ['pipe', 'pipe', 'pipe'], }) ctx.body = { success: true, message: output.trim() } + // Restart the server after response is sent + setTimeout(() => process.exit(0), 1000) } catch (err: any) { ctx.status = 500 ctx.body = { success: false, message: err.stderr || err.message } @@ -168,7 +173,7 @@ export async function bootstrap() { // Check for updates every 4 hours checkLatestVersion() - setInterval(checkLatestVersion, 4 * 60 * 60 * 1000) + setInterval(checkLatestVersion, 60 * 60 * 1000) } // ============================ diff --git a/packages/server/src/routes/upload.ts b/packages/server/src/routes/upload.ts index 34d0b98..838d6d6 100644 --- a/packages/server/src/routes/upload.ts +++ b/packages/server/src/routes/upload.ts @@ -22,31 +22,60 @@ uploadRoutes.post('/upload', async (ctx) => { await mkdir(config.uploadDir, { recursive: true }) - // Read raw body + // Read raw body as Buffer const chunks: Buffer[] = [] for await (const chunk of ctx.req) chunks.push(chunk) - const body = Buffer.concat(chunks).toString('latin1') - const parts = body.split(boundary).slice(1, -1) + const raw = Buffer.concat(chunks) + const boundaryBuf = Buffer.from(boundary) + const parts = splitMultipart(raw, boundaryBuf) const results: { name: string; path: string }[] = [] for (const part of parts) { - const headerEnd = part.indexOf('\r\n\r\n') + const headerEnd = part.indexOf(Buffer.from('\r\n\r\n')) if (headerEnd === -1) continue - const header = part.substring(0, headerEnd) - const data = part.substring(headerEnd + 4, part.length - 2) + const headerBuf = part.subarray(0, headerEnd) + const header = headerBuf.toString('utf-8') + const data = part.subarray(headerEnd + 4, part.length - 2) - const filenameMatch = header.match(/filename="([^"]+)"/) - if (!filenameMatch) continue + // Try RFC 5987 filename* first, fall back to filename + let filename = '' + const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i) + if (filenameStarMatch) { + filename = decodeURIComponent(filenameStarMatch[1]) + } else { + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + filename = filenameMatch[1] + } - const filename = filenameMatch[1] const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' const savedName = randomBytes(8).toString('hex') + ext const savedPath = `${config.uploadDir}/${savedName}` - await writeFile(savedPath, Buffer.from(data, 'binary')) + await writeFile(savedPath, data) results.push({ name: filename, path: savedPath }) } ctx.body = { files: results } }) + +/** + * Split a multipart Buffer by boundary, returning part Buffers. + * Avoids string decoding so multi-byte characters (e.g. Chinese filenames) are preserved. + */ +function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] { + const parts: Buffer[] = [] + let start = 0 + while (true) { + const idx = raw.indexOf(boundary, start) + if (idx === -1) break + if (start > 0) { + // Skip the \r\n after boundary + const partStart = start + 2 + parts.push(raw.subarray(partStart, idx)) + } + start = idx + boundary.length + } + return parts +}