From da500d4b7d589ca25bbb5b2316571345c3bc8cdd Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:26:06 +0300 Subject: [PATCH] Fetch authenticated media through the session for "Save image as" (#33997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apps/desktop/src/save-image.test.ts | 135 ++++++++++++++++++++++++ apps/desktop/src/save-image.ts | 52 +++++++++ apps/desktop/src/webcontents-handler.ts | 27 +---- 3 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/save-image.test.ts create mode 100644 apps/desktop/src/save-image.ts diff --git a/apps/desktop/src/save-image.test.ts b/apps/desktop/src/save-image.test.ts new file mode 100644 index 0000000000..f1ac82db6f --- /dev/null +++ b/apps/desktop/src/save-image.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/desktop/src/save-image.ts b/apps/desktop/src/save-image.ts new file mode 100644 index 0000000000..640c42a015 --- /dev/null +++ b/apps/desktop/src/save-image.ts @@ -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 { + 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 { + 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)); + } +} diff --git a/apps/desktop/src/webcontents-handler.ts b/apps/desktop/src/webcontents-handler.ts index c2e66ad5a0..3dc3d26ca0 100644 --- a/apps/desktop/src/webcontents-handler.ts +++ b/apps/desktop/src/webcontents-handler.ts @@ -7,13 +7,11 @@ Please see LICENSE files in the repository root for full details. import { clipboard, - nativeImage, Menu, MenuItem, shell, dialog, ipcMain, - type NativeImage, type WebContents, type ContextMenuParams, type DownloadItem, @@ -22,11 +20,10 @@ import { type Event, } from "electron"; import url from "node:url"; -import fs from "node:fs"; -import { pipeline } from "node:stream/promises"; import path from "node:path"; import { _t } from "./language-helper.js"; +import { saveImageToFile } from "./save-image.js"; import { getConfig } from "./config.js"; const MAILTO_PREFIX = "mailto:"; @@ -57,19 +54,6 @@ function onWindowOrNavigate(ev: Event, target: string): void { safeOpenURL(target); } -function writeNativeImage(filePath: string, img: NativeImage): Promise { - 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 { let url = params.linkURL || params.srcURL; @@ -150,14 +134,7 @@ function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: We if (!filePath) return; // user cancelled dialog try { - if (url.startsWith("data:")) { - 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)); - } + await saveImageToFile(url, filePath, webContents.session); } catch (err) { console.error(err); void dialog.showMessageBox({