fix(desktop): preserve attachment download filenames

Fixes #368
This commit is contained in:
tianma-if committed 2026-09-08 20:45:51 +08:00
1 parent 466161284d
commit ea81d5144f
6 files changed
+84 -8

No files matched your search

+13 -6
View File
@@ -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 });
+30
View File
@@ -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" };
@@ -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);
+2 -2
View File
@@ -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();
}, []);
@@ -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({
+11
View File
@@ -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);