Fetch authenticated media through the session for "Save image as" (#33997)
* Fetch authenticated media through the session for "Save image as" The desktop "Save image as" context-menu item fetched http(s) images with the main-process global fetch(), which bypasses the session's webRequest interceptors — including the authenticated-media handlers in media-auth.ts that rewrite the download URL and attach the Authorization header. On modern Synapse (authenticated media, MSC3916) that fetch is rejected with 401/404, so saving an image failed (#32362). Extract the save logic into a dedicated save-image.ts and fetch network URLs through the image's Electron session (webContents.session.fetch) so the auth interceptors apply; data: URLs are still decoded directly into a NativeImage. The module also keeps the extension-aware encoding (jpg/jpeg/bmp/png) so its contract can be unit-tested. * Use the contributor's own copyright header on the new save-image files
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 hayaksi1
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, describe, it, beforeEach, vi, type Mock } from "vitest";
|
||||||
|
import { nativeImage, type Session } from "electron";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as streamPromises from "node:stream/promises";
|
||||||
|
|
||||||
|
import { saveImageToFile, writeNativeImage } from "./save-image.js";
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
nativeImage: {
|
||||||
|
createFromDataURL: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
default: {
|
||||||
|
createWriteStream: vi.fn(),
|
||||||
|
promises: {
|
||||||
|
writeFile: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:stream/promises", () => ({
|
||||||
|
pipeline: vi.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createFromDataURL = vi.mocked(nativeImage.createFromDataURL);
|
||||||
|
const createWriteStream = vi.mocked(fs.createWriteStream);
|
||||||
|
const writeFile = vi.mocked(fs.promises.writeFile);
|
||||||
|
const pipeline = vi.mocked(streamPromises.pipeline);
|
||||||
|
|
||||||
|
/** A stub {@link NativeImage} exposing the encoder methods `writeNativeImage` selects between. */
|
||||||
|
function stubNativeImage(): { toPNG: Mock; toJPEG: Mock; toBitmap: Mock } {
|
||||||
|
return {
|
||||||
|
toPNG: vi.fn(() => Buffer.from("png")),
|
||||||
|
toJPEG: vi.fn(() => Buffer.from("jpeg")),
|
||||||
|
toBitmap: vi.fn(() => Buffer.from("bmp")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake Electron {@link Session} exposing only the `fetch` method used by `saveImageToFile`. */
|
||||||
|
function fakeSession(fetchImpl: Mock): Session {
|
||||||
|
return { fetch: fetchImpl } as unknown as Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("save-image", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
createFromDataURL.mockReturnValue(stubNativeImage() as never);
|
||||||
|
createWriteStream.mockReturnValue({} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveImageToFile", () => {
|
||||||
|
it("decodes a data: URL into a NativeImage and writes it without fetching", async () => {
|
||||||
|
const session = fakeSession(vi.fn());
|
||||||
|
const globalFetch = vi.spyOn(globalThis, "fetch");
|
||||||
|
|
||||||
|
await saveImageToFile("data:image/png;base64,AAAA", "/tmp/out.png", session);
|
||||||
|
|
||||||
|
expect(createFromDataURL).toHaveBeenCalledWith("data:image/png;base64,AAAA");
|
||||||
|
expect(writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(globalFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches http(s) URLs through the injected session and pipes the body to disk", async () => {
|
||||||
|
const body = { kind: "stream" };
|
||||||
|
const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, body }));
|
||||||
|
const session = fakeSession(fetchImpl);
|
||||||
|
const writeStream = { kind: "writeStream" };
|
||||||
|
createWriteStream.mockReturnValue(writeStream as never);
|
||||||
|
const globalFetch = vi.spyOn(globalThis, "fetch");
|
||||||
|
|
||||||
|
await saveImageToFile("https://hs.example/_matrix/media/v3/download/x/y", "/tmp/out.png", session);
|
||||||
|
|
||||||
|
// Regression assertion (#32362): the injected session fetch is used so the media-auth
|
||||||
|
// webRequest interceptors apply; the main-process global fetch must NOT be called.
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith("https://hs.example/_matrix/media/v3/download/x/y");
|
||||||
|
expect(globalFetch).not.toHaveBeenCalled();
|
||||||
|
expect(createWriteStream).toHaveBeenCalledWith("/tmp/out.png");
|
||||||
|
expect(pipeline).toHaveBeenCalledWith(body, writeStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the session fetch responds with a non-ok status", async () => {
|
||||||
|
const fetchImpl = vi.fn(() => Promise.resolve({ ok: false, statusText: "Not Found" }));
|
||||||
|
const session = fakeSession(fetchImpl);
|
||||||
|
|
||||||
|
await expect(saveImageToFile("https://hs.example/image.png", "/tmp/out.png", session)).rejects.toThrow(
|
||||||
|
"unexpected response Not Found",
|
||||||
|
);
|
||||||
|
expect(pipeline).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the session fetch responds without a body", async () => {
|
||||||
|
const fetchImpl = vi.fn(() => Promise.resolve({ ok: true, body: null, statusText: "OK" }));
|
||||||
|
const session = fakeSession(fetchImpl);
|
||||||
|
|
||||||
|
await expect(saveImageToFile("https://hs.example/image.png", "/tmp/out.png", session)).rejects.toThrow(
|
||||||
|
"unexpected response has no body OK",
|
||||||
|
);
|
||||||
|
expect(pipeline).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeNativeImage", () => {
|
||||||
|
it("encodes .jpg/.jpeg as JPEG", async () => {
|
||||||
|
const img = stubNativeImage();
|
||||||
|
await writeNativeImage("/tmp/out.jpg", img as never);
|
||||||
|
expect(img.toJPEG).toHaveBeenCalledWith(100);
|
||||||
|
expect(img.toPNG).not.toHaveBeenCalled();
|
||||||
|
expect(img.toBitmap).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes .bmp as a bitmap", async () => {
|
||||||
|
const img = stubNativeImage();
|
||||||
|
await writeNativeImage("/tmp/out.bmp", img as never);
|
||||||
|
expect(img.toBitmap).toHaveBeenCalled();
|
||||||
|
expect(img.toPNG).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes unknown extensions as PNG", async () => {
|
||||||
|
const img = stubNativeImage();
|
||||||
|
await writeNativeImage("/tmp/out.weird", img as never);
|
||||||
|
expect(img.toPNG).toHaveBeenCalled();
|
||||||
|
expect(img.toJPEG).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 hayaksi1
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nativeImage, type NativeImage, type Session } from "electron";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an Electron {@link NativeImage} to disk, encoding it based on the target file extension.
|
||||||
|
* Falls back to PNG for unknown extensions.
|
||||||
|
*/
|
||||||
|
export function writeNativeImage(filePath: string, img: NativeImage): Promise<void> {
|
||||||
|
switch (filePath.split(".").pop()?.toLowerCase()) {
|
||||||
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
return fs.promises.writeFile(filePath, img.toJPEG(100));
|
||||||
|
case "bmp":
|
||||||
|
return fs.promises.writeFile(filePath, img.toBitmap());
|
||||||
|
case "png":
|
||||||
|
default:
|
||||||
|
return fs.promises.writeFile(filePath, img.toPNG());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an image to a file on disk.
|
||||||
|
*
|
||||||
|
* `data:` URLs are decoded directly into a {@link NativeImage}. Network (`http(s):`) URLs are
|
||||||
|
* fetched through the supplied Electron {@link Session} rather than Node's global `fetch`, so that
|
||||||
|
* the session's `webRequest` interceptors apply — in particular the authenticated-media handlers in
|
||||||
|
* `media-auth.ts` which rewrite the download URL and attach the `Authorization` header. Using the
|
||||||
|
* main-process global `fetch` bypasses those interceptors and fails with 401/404 on modern Synapse
|
||||||
|
* (authenticated media, MSC3916). See https://github.com/element-hq/element-web/issues/32362.
|
||||||
|
*
|
||||||
|
* @param url - the `data:` or `http(s):` URL of the image to save
|
||||||
|
* @param filePath - the destination path on disk
|
||||||
|
* @param session - the Electron session whose `webRequest` interceptors should apply to the fetch
|
||||||
|
*/
|
||||||
|
export async function saveImageToFile(url: string, filePath: string, session: Session): Promise<void> {
|
||||||
|
if (url.startsWith("data:")) {
|
||||||
|
await writeNativeImage(filePath, nativeImage.createFromDataURL(url));
|
||||||
|
} else {
|
||||||
|
const resp = await session.fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`);
|
||||||
|
if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`);
|
||||||
|
await pipeline(resp.body, fs.createWriteStream(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,11 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
clipboard,
|
clipboard,
|
||||||
nativeImage,
|
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
shell,
|
shell,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
type NativeImage,
|
|
||||||
type WebContents,
|
type WebContents,
|
||||||
type ContextMenuParams,
|
type ContextMenuParams,
|
||||||
type DownloadItem,
|
type DownloadItem,
|
||||||
@@ -22,11 +20,10 @@ import {
|
|||||||
type Event,
|
type Event,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import url from "node:url";
|
import url from "node:url";
|
||||||
import fs from "node:fs";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { _t } from "./language-helper.js";
|
import { _t } from "./language-helper.js";
|
||||||
|
import { saveImageToFile } from "./save-image.js";
|
||||||
import { getConfig } from "./config.js";
|
import { getConfig } from "./config.js";
|
||||||
|
|
||||||
const MAILTO_PREFIX = "mailto:";
|
const MAILTO_PREFIX = "mailto:";
|
||||||
@@ -57,19 +54,6 @@ function onWindowOrNavigate(ev: Event, target: string): void {
|
|||||||
safeOpenURL(target);
|
safeOpenURL(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeNativeImage(filePath: string, img: NativeImage): Promise<void> {
|
|
||||||
switch (filePath.split(".").pop()?.toLowerCase()) {
|
|
||||||
case "jpg":
|
|
||||||
case "jpeg":
|
|
||||||
return fs.promises.writeFile(filePath, img.toJPEG(100));
|
|
||||||
case "bmp":
|
|
||||||
return fs.promises.writeFile(filePath, img.toBitmap());
|
|
||||||
case "png":
|
|
||||||
default:
|
|
||||||
return fs.promises.writeFile(filePath, img.toPNG());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
|
function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
|
||||||
let url = params.linkURL || params.srcURL;
|
let url = params.linkURL || params.srcURL;
|
||||||
|
|
||||||
@@ -150,14 +134,7 @@ function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: We
|
|||||||
if (!filePath) return; // user cancelled dialog
|
if (!filePath) return; // user cancelled dialog
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (url.startsWith("data:")) {
|
await saveImageToFile(url, filePath, webContents.session);
|
||||||
await writeNativeImage(filePath, nativeImage.createFromDataURL(url));
|
|
||||||
} else {
|
|
||||||
const resp = await fetch(url);
|
|
||||||
if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`);
|
|
||||||
if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`);
|
|
||||||
await pipeline(resp.body, fs.createWriteStream(filePath));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
void dialog.showMessageBox({
|
void dialog.showMessageBox({
|
||||||
|
|||||||
Reference in New Issue
Block a user