Skip to content

Commit 853e632

Browse files
committed
feat(extension): bypass CORS for cross-origin images during export via background fetch
1 parent 6376a0e commit 853e632

4 files changed

Lines changed: 166 additions & 2 deletions

File tree

app/extension/public/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "__MSG_extensionName__",
44
"description": "__MSG_extensionDescription__",
55
"default_locale": "en",
6-
"version": "0.5.3",
6+
"version": "0.5.4",
77
"options_ui": {
88
"page": "options.html",
99
"open_in_tab": true

app/extension/src/background.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,27 @@ const badgeCache = new Map<number, string>();
5050
const SAVED_BADGE_TEXT = "✓";
5151
const SAVED_BADGE_BG = "#15803D";
5252

53+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
54+
const bytes = new Uint8Array(buffer);
55+
const chunkSize = 0x8000;
56+
let binary = "";
57+
58+
for (let index = 0; index < bytes.length; index += chunkSize) {
59+
const chunk = bytes.subarray(index, index + chunkSize);
60+
binary += String.fromCharCode(...chunk);
61+
}
62+
63+
return btoa(binary);
64+
}
65+
66+
async function blobToDataUrl(blob: Blob): Promise<string> {
67+
const contentType = blob.type || "application/octet-stream";
68+
const buffer = await blob.arrayBuffer();
69+
const base64 = arrayBufferToBase64(buffer);
70+
71+
return `data:${contentType};base64,${base64}`;
72+
}
73+
5374
/**
5475
* Update the badge for a tab based on whether the page is saved in Huntly
5576
* @param tabId The tab ID to update
@@ -583,6 +604,34 @@ chrome.runtime.onMessage.addListener(function (
583604
if (url) {
584605
chrome.tabs.create({ url });
585606
}
607+
} else if ((msg as any).type === "fetch_image") {
608+
// Fetch a cross-origin image from the background (privileged context)
609+
// and return it as a data URL for export.
610+
const imageUrl = msg.payload?.url;
611+
if (!imageUrl) {
612+
sendResponse({ success: false, error: "No URL provided" });
613+
return;
614+
}
615+
(async () => {
616+
try {
617+
const response = await fetch(imageUrl, { credentials: "omit" });
618+
if (!response.ok) {
619+
sendResponse({ success: false, error: `HTTP ${response.status}` });
620+
return;
621+
}
622+
const contentType = response.headers.get("content-type") || "";
623+
if (!contentType.startsWith("image/")) {
624+
sendResponse({ success: false, error: "Not an image" });
625+
return;
626+
}
627+
const blob = await response.blob();
628+
const dataUrl = await blobToDataUrl(blob);
629+
sendResponse({ success: true, dataUrl });
630+
} catch (error) {
631+
sendResponse({ success: false, error: (error as Error)?.message || "Fetch failed" });
632+
}
633+
})();
634+
return true; // Keep the message channel open for async response
586635
} else if ((msg as any).type === "badge_refresh") {
587636
// Refresh badge for a specific tab after manual save/delete from popup
588637
const tabId = msg.payload?.tabId;

app/extension/src/model.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface ShortcutPayload {
2626
}
2727

2828
interface Message {
29-
type: "auto_save_clipper" | "save_clipper" | 'tab_complete' | 'auto_save_tweets' | 'read_tweet' | 'parse_doc' | 'save_clipper_success' | 'shortcuts_preview' | 'shortcuts_execute' | 'shortcuts_process' | 'shortcuts_cancel' | 'shortcuts_processing_start' | 'shortcuts_process_result' | 'shortcuts_process_data' | 'shortcuts_process_error' | 'get_selection' | 'detect_rss_feed' | 'get_huntly_shortcuts' | 'get_ai_toolbar_data' | 'open_tab' | 'save_detail_init' | 'http_proxy' | 'badge_refresh',
29+
type: "auto_save_clipper" | "save_clipper" | 'tab_complete' | 'auto_save_tweets' | 'read_tweet' | 'parse_doc' | 'save_clipper_success' | 'shortcuts_preview' | 'shortcuts_execute' | 'shortcuts_process' | 'shortcuts_cancel' | 'shortcuts_processing_start' | 'shortcuts_process_result' | 'shortcuts_process_data' | 'shortcuts_process_error' | 'get_selection' | 'detect_rss_feed' | 'get_huntly_shortcuts' | 'get_ai_toolbar_data' | 'open_tab' | 'save_detail_init' | 'http_proxy' | 'badge_refresh' | 'fetch_image',
3030
payload?: any,
3131
url?: string
3232
}

app/extension/src/utils/exportUtils.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,13 @@ function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
557557
try {
558558
canvas.toBlob((blob) => {
559559
if (!blob) {
560+
try {
561+
canvas.toDataURL("image/png");
562+
} catch (error) {
563+
reject(error);
564+
return;
565+
}
566+
560567
reject(new Error("Failed to create image blob"));
561568
return;
562569
}
@@ -569,6 +576,101 @@ function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
569576
});
570577
}
571578

579+
/**
580+
* Fetch an image via the background service worker (privileged context)
581+
* which bypasses CORS restrictions that apply to content scripts in MV3.
582+
*/
583+
function fetchImageViaBackground(imageUrl: string): Promise<string | null> {
584+
return new Promise((resolve) => {
585+
try {
586+
chrome.runtime.sendMessage(
587+
{ type: "fetch_image", payload: { url: imageUrl } },
588+
(response) => {
589+
if (chrome.runtime.lastError || !response?.success) {
590+
resolve(null);
591+
return;
592+
}
593+
resolve(response.dataUrl);
594+
}
595+
);
596+
} catch {
597+
resolve(null);
598+
}
599+
});
600+
}
601+
602+
/**
603+
* Convert export-unsafe images to data URLs using the background service
604+
* worker's privileged fetch. This covers both explicit cross-origin images
605+
* and same-origin URLs that redirect to a different origin at load time,
606+
* such as GitHub's /raw/ image routes.
607+
*/
608+
async function convertCrossOriginImages(
609+
originalRoot: HTMLElement,
610+
clonedRoot: HTMLElement,
611+
sourceOrigin: string,
612+
baseUri: string
613+
): Promise<void> {
614+
const originalImages = collectElements<HTMLImageElement>(originalRoot, "img");
615+
const clonedImages = collectElements<HTMLImageElement>(clonedRoot, "img");
616+
const pairCount = Math.min(originalImages.length, clonedImages.length);
617+
618+
const conversions = Array.from({ length: pairCount }, async (_, index) => {
619+
const originalImage = originalImages[index];
620+
const clonedImage = clonedImages[index];
621+
const src =
622+
clonedImage.getAttribute("src") ||
623+
originalImage.currentSrc ||
624+
originalImage.getAttribute("src");
625+
if (!src) return;
626+
627+
const url = parseExportResourceUrl(src, baseUri);
628+
if (!url) return;
629+
630+
// Skip non-HTTP URLs – data:, blob:, extension: are already safe.
631+
if (url.protocol !== "http:" && url.protocol !== "https:") return;
632+
633+
const imageLoaded = Boolean(
634+
originalImage.complete &&
635+
originalImage.naturalWidth > 0 &&
636+
originalImage.naturalHeight > 0
637+
);
638+
639+
if (imageLoaded && isImageExportSafe(originalImage)) {
640+
return;
641+
}
642+
643+
// Avoid fetching ordinary same-origin images that simply haven't loaded
644+
// yet. If the already-loaded image is still unsafe, fetch it regardless
645+
// of origin to handle redirecting asset URLs.
646+
if (!imageLoaded && url.origin === sourceOrigin) {
647+
return;
648+
}
649+
650+
try {
651+
const dataUrl = await fetchImageViaBackground(url.href);
652+
if (!dataUrl) return;
653+
654+
clonedImage.src = dataUrl;
655+
656+
// Clear srcset / <picture> <source>s so the browser uses the data URL.
657+
if (clonedImage.srcset) {
658+
clonedImage.removeAttribute("srcset");
659+
}
660+
if (clonedImage.parentElement instanceof HTMLPictureElement) {
661+
clonedImage.parentElement
662+
.querySelectorAll("source")
663+
.forEach((sourceNode) => sourceNode.remove());
664+
}
665+
} catch {
666+
// Fetch failed – leave the original src; the existing sanitize
667+
// fallback will replace it with a placeholder if needed.
668+
}
669+
});
670+
671+
await Promise.all(conversions);
672+
}
673+
572674
function waitForNextPaint(targetDocument: Document): Promise<void> {
573675
const view = targetDocument.defaultView;
574676

@@ -846,6 +948,19 @@ export async function elementToCanvas(
846948
try {
847949
const targetDocument = exportRoot.ownerDocument;
848950
const sourceDocument = element.ownerDocument;
951+
const sourceOrigin =
952+
sourceDocument.location?.origin || window.location.origin;
953+
const sourceBaseUri = sourceDocument.baseURI;
954+
955+
// Convert export-unsafe images to data URLs before html2canvas runs so
956+
// they can be rendered without tainting the canvas.
957+
await convertCrossOriginImages(
958+
element,
959+
exportRoot,
960+
sourceOrigin,
961+
sourceBaseUri
962+
);
963+
849964
await waitForStylesheets(targetDocument);
850965
await copyFontFaces(sourceDocument, targetDocument);
851966
await waitForFonts(targetDocument);

0 commit comments

Comments
 (0)