From d634b50a998e605bc23aa8feac4c9b5660ca377e Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Thu, 23 Jul 2026 10:54:19 +0300 Subject: [PATCH] Embed QR PNGs as data URLs for file:// downloads Chrome blocks fetch/canvas for sibling file:// images (CORS/tainted). Inline base64 in HTML so lightbox and .png chips download offline. --- .../templates/export/global-index.html.j2 | 42 ++++++++++++++----- .../hysteria2/templates/export/index.html.j2 | 42 ++++++++++++++----- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/roles/hysteria2/templates/export/global-index.html.j2 b/roles/hysteria2/templates/export/global-index.html.j2 index d112506..b2c3d5f 100644 --- a/roles/hysteria2/templates/export/global-index.html.j2 +++ b/roles/hysteria2/templates/export/global-index.html.j2 @@ -718,16 +718,19 @@ +{% set _png = namespace(b64='', data='') %} {% if user.has_png | default(false) %} +{% set _png.b64 = lookup('pipe', 'base64 < ' ~ ((hysteria2_output_dir ~ '/' ~ server.dir ~ '/' ~ user.name ~ '.png') | quote)) | regex_replace('\\s+', '') %} +{% set _png.data = 'data:image/png;base64,' ~ _png.b64 %}
QR {{ user.name | e }} .url .txt -{% if user.has_png | default(false) %} - .png +{% if user.has_png | default(false) and _png.b64 %} + .png {% endif %} - .qr.txt + .qr.txt {% endfor %} @@ -870,10 +873,16 @@ e.preventDefault(); e.stopPropagation(); var filename = downloadBtn.getAttribute('data-filename') || 'qr.png'; - if (downloadQrFromCanvas(filename)) return; - var src = downloadSrc || img.currentSrc || img.src; - if (!src) return; + + // Embedded data: URLs work on file:// (no CORS / tainted canvas) + if (src && src.indexOf('data:') === 0) { + triggerDataUrlDownload(src, filename); + return; + } + if (downloadQrFromCanvas(filename)) return; + if (!src || location.protocol === 'file:') return; + fetch(src) .then(function (res) { if (!res.ok) throw new Error('fetch failed'); @@ -960,7 +969,13 @@ function pngFromCard(link, filename) { var card = link.closest('.user-card'); var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img'); - if (!qrImg || !qrImg.complete || !qrImg.naturalWidth) return false; + if (!qrImg) return false; + var src = qrImg.currentSrc || qrImg.src || ''; + if (src.indexOf('data:image/') === 0) { + triggerDataUrlDownload(src, filename); + return true; + } + if (!qrImg.complete || !qrImg.naturalWidth) return false; try { var canvas = document.createElement('canvas'); canvas.width = qrImg.naturalWidth; @@ -980,15 +995,20 @@ var filename = link.getAttribute('data-filename') || link.getAttribute('download') || 'download'; var href = link.getAttribute('href'); var fallbackText = link.getAttribute('data-text'); + var pngB64 = link.getAttribute('data-png-b64'); var isPng = /\.png$/i.test(filename); - // Sync paths first — async fetch loses the user-gesture in Safari/Chrome + // Sync paths first — file:// blocks fetch (CORS) and taints canvas + if (pngB64) { + triggerDataUrlDownload('data:image/png;base64,' + pngB64, filename); + return; + } if (isPng && pngFromCard(link, filename)) return; if (fallbackText !== null) { triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); return; } - if (!href) return; + if (!href || location.protocol === 'file:') return; fetch(href) .then(function (res) { diff --git a/roles/hysteria2/templates/export/index.html.j2 b/roles/hysteria2/templates/export/index.html.j2 index 6834a34..a489be1 100644 --- a/roles/hysteria2/templates/export/index.html.j2 +++ b/roles/hysteria2/templates/export/index.html.j2 @@ -424,16 +424,19 @@
+{% set _png = namespace(b64='', data='') %} {% if user.has_png | default(false) %} +{% set _png.b64 = lookup('pipe', 'base64 < ' ~ ((hysteria2_output_dir ~ '/' ~ hysteria2_output_name ~ '/' ~ user.name ~ '.png') | quote)) | regex_replace('\\s+', '') %} +{% set _png.data = 'data:image/png;base64,' ~ _png.b64 %}
QR {{ user.name | e }} .url .txt -{% if user.has_png | default(false) %} - .png +{% if user.has_png | default(false) and _png.b64 %} + .png {% endif %} - .qr.txt + .qr.txt {% endfor %} @@ -547,10 +550,16 @@ e.preventDefault(); e.stopPropagation(); var filename = downloadBtn.getAttribute('data-filename') || 'qr.png'; - if (downloadQrFromCanvas(filename)) return; - var src = downloadSrc || img.currentSrc || img.src; - if (!src) return; + + // Embedded data: URLs work on file:// (no CORS / tainted canvas) + if (src && src.indexOf('data:') === 0) { + triggerDataUrlDownload(src, filename); + return; + } + if (downloadQrFromCanvas(filename)) return; + if (!src || location.protocol === 'file:') return; + fetch(src) .then(function (res) { if (!res.ok) throw new Error('fetch failed'); @@ -637,7 +646,13 @@ function pngFromCard(link, filename) { var card = link.closest('.user-card'); var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img'); - if (!qrImg || !qrImg.complete || !qrImg.naturalWidth) return false; + if (!qrImg) return false; + var src = qrImg.currentSrc || qrImg.src || ''; + if (src.indexOf('data:image/') === 0) { + triggerDataUrlDownload(src, filename); + return true; + } + if (!qrImg.complete || !qrImg.naturalWidth) return false; try { var canvas = document.createElement('canvas'); canvas.width = qrImg.naturalWidth; @@ -657,15 +672,20 @@ var filename = link.getAttribute('data-filename') || link.getAttribute('download') || 'download'; var href = link.getAttribute('href'); var fallbackText = link.getAttribute('data-text'); + var pngB64 = link.getAttribute('data-png-b64'); var isPng = /\.png$/i.test(filename); - // Sync paths first — async fetch loses the user-gesture in Safari/Chrome + // Sync paths first — file:// blocks fetch (CORS) and taints canvas + if (pngB64) { + triggerDataUrlDownload('data:image/png;base64,' + pngB64, filename); + return; + } if (isPng && pngFromCard(link, filename)) return; if (fallbackText !== null) { triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); return; } - if (!href) return; + if (!href || location.protocol === 'file:') return; fetch(href) .then(function (res) {