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 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-20 12:15:47 +08:00
parent 5d16d56d9f
commit eb6c2dc9f6
3 changed files with 48 additions and 14 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hermes-web-ui", "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)", "description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
"repository": { "repository": {
"type": "git", "type": "git",
+8 -3
View File
@@ -36,6 +36,7 @@ async function checkLatestVersion(): Promise<void> {
try { try {
const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', { const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
headers: { 'Cache-Control': 'no-cache' },
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@@ -83,9 +84,11 @@ export async function bootstrap() {
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
if (ctx.path === '/api/hermes/update' && ctx.method === 'POST') { if (ctx.path === '/api/hermes/update' && ctx.method === 'POST') {
const isWin = process.platform === 'win32' 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 const cmd = isWin
? 'cmd /c hermes-web-ui update' ? 'cmd /c npm install -g hermes-web-ui@latest'
: 'hermes-web-ui update' : 'npm install -g hermes-web-ui@latest'
try { try {
const { execSync } = await import('child_process') const { execSync } = await import('child_process')
@@ -95,6 +98,8 @@ export async function bootstrap() {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}) })
ctx.body = { success: true, message: output.trim() } ctx.body = { success: true, message: output.trim() }
// Restart the server after response is sent
setTimeout(() => process.exit(0), 1000)
} catch (err: any) { } catch (err: any) {
ctx.status = 500 ctx.status = 500
ctx.body = { success: false, message: err.stderr || err.message } ctx.body = { success: false, message: err.stderr || err.message }
@@ -168,7 +173,7 @@ export async function bootstrap() {
// Check for updates every 4 hours // Check for updates every 4 hours
checkLatestVersion() checkLatestVersion()
setInterval(checkLatestVersion, 4 * 60 * 60 * 1000) setInterval(checkLatestVersion, 60 * 60 * 1000)
} }
// ============================ // ============================
+37 -8
View File
@@ -22,31 +22,60 @@ uploadRoutes.post('/upload', async (ctx) => {
await mkdir(config.uploadDir, { recursive: true }) await mkdir(config.uploadDir, { recursive: true })
// Read raw body // Read raw body as Buffer
const chunks: Buffer[] = [] const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk) for await (const chunk of ctx.req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString('latin1') const raw = Buffer.concat(chunks)
const parts = body.split(boundary).slice(1, -1) const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const results: { name: string; path: string }[] = [] const results: { name: string; path: string }[] = []
for (const part of parts) { 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 if (headerEnd === -1) continue
const header = part.substring(0, headerEnd) const headerBuf = part.subarray(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2) const header = headerBuf.toString('utf-8')
const data = part.subarray(headerEnd + 4, part.length - 2)
// 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="([^"]+)"/) const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue if (!filenameMatch) continue
filename = filenameMatch[1]
}
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext const savedName = randomBytes(8).toString('hex') + ext
const savedPath = `${config.uploadDir}/${savedName}` const savedPath = `${config.uploadDir}/${savedName}`
await writeFile(savedPath, Buffer.from(data, 'binary')) await writeFile(savedPath, data)
results.push({ name: filename, path: savedPath }) results.push({ name: filename, path: savedPath })
} }
ctx.body = { files: results } 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
}