From ea81d5144fc5ca223a6989cdbb10f7d48641b562 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 8 Sep 2026 20:45:51 +0800 Subject: [PATCH] fix(desktop): preserve attachment download filenames Fixes #368 --- apps/desktop/src/main/index.mjs | 19 ++++++++----- apps/desktop/src/main/resource-url.mjs | 30 +++++++++++++++++++++ apps/desktop/src/main/resource-url.test.mjs | 14 ++++++++++ apps/web/src/components/EditorPane.tsx | 4 +-- apps/web/src/lib/desktop-resources.test.mjs | 14 ++++++++++ apps/web/src/lib/desktop-resources.ts | 11 ++++++++ 6 files changed, 84 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 0fb6e9b7..a930e532 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { release as operatingSystemRelease } from "node:os"; import { SidecarRpcClient } from "./rpc.mjs"; import { resourceRequestHeaders } from "./resource-request.mjs"; -import { isSafeResourceId, parseByteRangeHeader, resourceIdFromRequest } from "./resource-url.mjs"; +import { downloadContentDispositionFromRequest, isSafeResourceId, parseByteRangeHeader, resourceIdFromRequest } from "./resource-url.mjs"; import { isSupportedAssociatedFile } from "./file-association.mjs"; import { accountDataDirectory, accountScopeKey } from "./account-scope.mjs"; import { rotateDiagnosticLog } from "./diagnostic-log.mjs"; @@ -581,6 +581,7 @@ const createTray = () => { const handleResourceProtocolRequest = async (request) => { const resourceId = resourceIdFromRequest(request.url); if (!resourceId) return new Response("Invalid resource", { status: 400 }); + const downloadDisposition = downloadContentDispositionFromRequest(request.url); const directory = resourceCacheDirectory(); const bytesPath = join(directory, `${resourceId}.bin`); @@ -594,6 +595,7 @@ const handleResourceProtocolRequest = async (request) => { "Cache-Control": "no-store", "Content-Type": contentType || "application/octet-stream", }); + if (downloadDisposition) headers.set("Content-Disposition", downloadDisposition); if (range.kind === "invalid") { headers.set("Content-Range", `bytes */${size}`); return new Response(null, { status: 416, headers }); @@ -640,6 +642,7 @@ const handleResourceProtocolRequest = async (request) => { const value = response.headers.get(name); if (value) responseHeaders.set(name, value); } + if (downloadDisposition) responseHeaders.set("Content-Disposition", downloadDisposition); return new Response(response.body, { status: 206, headers: responseHeaders }); } if (!response.body) return new Response("Resource response body is empty", { status: 502 }); @@ -684,6 +687,7 @@ const handleResourceProtocolRequest = async (request) => { }); const responseHeaders = new Headers(response.headers); responseHeaders.set("Cache-Control", "no-store"); + if (downloadDisposition) responseHeaders.set("Content-Disposition", downloadDisposition); return new Response(streamedBody, { status: 200, headers: responseHeaders }); } catch (error) { void writeDiagnostic("resource.cache-failed", { resourceId, message: error.message }); @@ -704,12 +708,15 @@ const registerResourceProtocol = () => { const path = join(directory, `${stagedId}.bin`); const { size } = await stat(path); const stream = createReadStream(path); + const headers = new Headers({ + "Content-Type": metadata.type || "application/octet-stream", + "Content-Length": String(size), + "Cache-Control": "no-store", + }); + const downloadDisposition = downloadContentDispositionFromRequest(request.url); + if (downloadDisposition) headers.set("Content-Disposition", downloadDisposition); return new Response(Readable.toWeb(stream), { - headers: { - "Content-Type": metadata.type || "application/octet-stream", - "Content-Length": String(size), - "Cache-Control": "no-store", - }, + headers, }); } catch (error) { void writeDiagnostic("resource.staged-read-failed", { stagedId, message: error.message }); diff --git a/apps/desktop/src/main/resource-url.mjs b/apps/desktop/src/main/resource-url.mjs index a16eff2e..e7f75ce4 100644 --- a/apps/desktop/src/main/resource-url.mjs +++ b/apps/desktop/src/main/resource-url.mjs @@ -12,6 +12,36 @@ export const resourceIdFromRequest = (requestUrl) => { } }; +const normalizeDownloadFilename = (filename) => filename + .trim() + .replace(/[\\/]/g, "-") + .replace(/[\u0000-\u001f\u007f]/g, "") + .slice(0, 160); + +const encodeRfc5987Value = (value) => encodeURIComponent( + value.replace(/[\uD800-\uDFFF]/gu, "\uFFFD"), +).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); + +const asciiFilenameFallback = (filename) => { + const normalized = filename.normalize("NFKD").replace(/[\u0300-\u036f]/g, ""); + const ascii = normalized.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const extension = /\.[A-Za-z0-9]{1,16}$/.exec(ascii)?.[0] ?? ""; + const basename = extension ? ascii.slice(0, -extension.length) : ascii; + return /[A-Za-z0-9]/.test(basename) ? ascii : `download${extension}`; +}; + +export const downloadContentDispositionFromRequest = (requestUrl) => { + try { + const requested = new URL(requestUrl).searchParams.get("download"); + if (!requested) return null; + const filename = normalizeDownloadFilename(requested); + if (!filename) return null; + return `attachment; filename="${asciiFilenameFallback(filename)}"; filename*=UTF-8''${encodeRfc5987Value(filename)}`; + } catch { + return null; + } +}; + export const parseByteRangeHeader = (header, totalSize) => { if (!header) return { kind: "none" }; if (!Number.isSafeInteger(totalSize) || totalSize <= 0) return { kind: "invalid" }; diff --git a/apps/desktop/src/main/resource-url.test.mjs b/apps/desktop/src/main/resource-url.test.mjs index 5a9e7c45..ce7cb2f2 100644 --- a/apps/desktop/src/main/resource-url.test.mjs +++ b/apps/desktop/src/main/resource-url.test.mjs @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { cachedResourceResponse, + downloadContentDispositionFromRequest, isSafeResourceId, parseByteRangeHeader, resourceIdFromRequest, @@ -15,6 +16,19 @@ test("desktop resource URL parsing accepts safe resource and staged IDs", () => expect(resourceIdFromRequest("edgeever-staged://stage_%ZZ")).toBeNull(); }); +test("desktop resource downloads preserve safe UTF-8 filenames", () => { + expect(downloadContentDispositionFromRequest( + "edgeever-resource://resource/resource_123?download=%E8%B5%84%E6%96%99%E5%8C%85.zip", + )).toBe( + "attachment; filename=\"download.zip\"; filename*=UTF-8''%E8%B5%84%E6%96%99%E5%8C%85.zip", + ); + expect(downloadContentDispositionFromRequest( + "edgeever-resource://resource/resource_123?download=reports%2Farchive.zip", + )).toContain('filename="reports-archive.zip"'); + expect(downloadContentDispositionFromRequest("edgeever-resource://resource/resource_123")) + .toBeNull(); +}); + test("desktop staged resource IPC IDs reject path traversal", () => { expect(isSafeResourceId("stage_123")).toBe(true); expect(isSafeResourceId("../edgeever.sqlite")).toBe(false); diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index db11fc46..d70603d0 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -106,7 +106,7 @@ import { memoShareQueryKey, ShareMemoDialog } from "./dialogs/ShareMemoDialog"; import { ShareNoteImageDialog, type ShareNoteImageSource } from "./dialogs/ShareNoteImageDialog"; import { AiAssistantDialog, type AiAssistantAnchor } from "./dialogs/AiAssistantDialog"; import { api } from "@/lib/api"; -import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources"; +import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceDownloadUrl, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; import { EDITOR_CONTENT_MAX_WIDTH, EDITOR_CONTENT_MAX_WIDTH_COLLAPSED } from "@/lib/workspace-ui"; import { @@ -3224,7 +3224,7 @@ const RichEditorPane = ({ const downloadResourceDirectly = useCallback((target: ResourceMenuTarget) => { const anchor = document.createElement("a"); - anchor.href = target.url; + anchor.href = toDesktopResourceDownloadUrl(target.url, target.filename); anchor.download = target.filename; anchor.click(); }, []); diff --git a/apps/web/src/lib/desktop-resources.test.mjs b/apps/web/src/lib/desktop-resources.test.mjs index 2b5db994..38fe4db8 100644 --- a/apps/web/src/lib/desktop-resources.test.mjs +++ b/apps/web/src/lib/desktop-resources.test.mjs @@ -21,6 +21,7 @@ const { createStagedResourceListItem, stageDesktopResource, toApiResourceUrl, + toDesktopResourceDownloadUrl, toDesktopResourceUrl, } = await import("./desktop-resources.ts"); @@ -30,6 +31,19 @@ describe("desktop resource URLs", () => { expect(toDesktopResourceUrl("https://cdn.example.com/image.png")).toBe("https://cdn.example.com/image.png"); }); + test("adds the requested filename to native resource downloads", () => { + expect(toDesktopResourceDownloadUrl( + "edgeever-resource://resource/resource-1", + "资料包.zip", + )).toBe("edgeever-resource://resource/resource-1?download=%E8%B5%84%E6%96%99%E5%8C%85.zip"); + expect(toDesktopResourceDownloadUrl( + "edgeever-staged://stage-1", + "offline.zip", + )).toBe("edgeever-staged://stage-1?download=offline.zip"); + expect(toDesktopResourceDownloadUrl("/api/v1/resources/resource-1/blob", "archive.zip")) + .toBe("/api/v1/resources/resource-1/blob"); + }); + test("restores portable API URLs before a memo is saved", () => { expect(toApiResourceUrl("edgeever-resource://resource/resource-1")).toBe("/api/v1/resources/resource-1/blob"); const mapped = mapTiptapResourceUrls({ diff --git a/apps/web/src/lib/desktop-resources.ts b/apps/web/src/lib/desktop-resources.ts index 1e8b5a58..9630bb2f 100644 --- a/apps/web/src/lib/desktop-resources.ts +++ b/apps/web/src/lib/desktop-resources.ts @@ -27,6 +27,17 @@ export const toDesktopResourceUrl = (url: string) => { return resourceId ? `edgeever-resource://resource/${encodeURIComponent(resourceId)}` : url; }; +export const toDesktopResourceDownloadUrl = (url: string, filename: string) => { + if (!isDesktopResourceRuntime() || (!url.startsWith("edgeever-resource://") && !url.startsWith("edgeever-staged://"))) return url; + try { + const parsed = new URL(url); + parsed.searchParams.set("download", filename); + return parsed.toString(); + } catch { + return url; + } +}; + export const toApiResourceUrl = (url: string) => { if (!url.startsWith("edgeever-resource://")) return url; const resourceId = getResourceId(url);