1 parent
466161284d
commit
ea81d5144f
6 files changed
+84
-8
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user