Compare commits

..

37 Commits

Author SHA1 Message Date
inecs ca1011954f 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.
2026-07-23 10:54:19 +03:00
inecs 6932496bfc Fix lightbox QR download: sync canvas.toDataURL
Async fetch/toBlob lost the user-gesture so Safari/Chrome opened
the PNG instead of downloading. Use a button and sync data-URL save.
2026-07-23 10:34:13 +03:00
inecs 396682459c docs: восстановить README salamander и пункт про скачивание файлов 2026-07-23 10:30:16 +03:00
inecs 7f644b693b docs: скачивание .url/.txt/.png/.qr.txt в «Последние обновления» 2026-07-23 10:29:56 +03:00
inecs c1b26ff35b Force download for .url/.txt/.png/.qr.txt chips
Browser often opens these files instead of downloading; use blob
downloads with embedded text fallbacks and canvas for PNG on file://.
2026-07-23 10:28:14 +03:00
inecs e486e1df86 Принудительное скачивание QR из lightbox как server-user.png
Кнопка больше не открывает PNG во вкладке: blob/canvas download
с именем <сервер>-<пользователь>.png.
2026-07-23 10:23:16 +03:00
inecs 78f4d05ceb docs: раздел «Последние обновления» за 2026-07-23 2026-07-23 09:42:47 +03:00
inecs 0ea10c40fc make index и install/update: единый стиль страниц серверов и каталога
Пересборка HTML серверов из server-info.yml; сетка карточек и вид
как у главной. Тот же pipeline после install/update/export.
2026-07-23 09:35:47 +03:00
inecs 94be253768 Убрать подсказку закрытия из lightbox 2026-07-23 09:31:20 +03:00
inecs 651ec2daee Сетка карточек на странице сервера и крупный QR в lightbox
Карточки как в глобальном индексе; QR заполняет доступную
площадь модального окна через container queries.
2026-07-23 09:25:22 +03:00
inecs ac85a43c11 docs: упомянуть вкладки и статистику в make index 2026-07-23 09:21:24 +03:00
inecs ade2ea0f9a Вкладки серверов и страница статистики в output/index.html
По умолчанию — Статистика: доступность TCP, IP, пользователи,
матрица присутствия. Серверы открываются отдельными вкладками.
2026-07-23 09:21:04 +03:00
inecs 28df4f4a04 Сделать lightbox на всю высоту экрана с небольшими отступами
Панель занимает 100dvh минус 1rem сверху/снизу; QR растягивается
в оставшееся пространство.
2026-07-23 09:16:45 +03:00
inecs 912938e6cf Увеличить QR в lightbox на 30% (до ~728px) 2026-07-23 09:13:58 +03:00
inecs a39e5b7d16 Ограничить lightbox QR высотой экрана с отступами сверху/снизу 2026-07-23 09:13:00 +03:00
inecs 212e891409 Увеличить QR в lightbox до ~560px 2026-07-23 09:11:14 +03:00
inecs c3b3bbb8a0 Добавить кнопку скачивания QR в lightbox
Файл сохраняется как <сервер>-<пользователь>.png.
2026-07-23 09:09:12 +03:00
inecs 2ea3dadc7b Добавить lightbox для QR-кодов в HTML-каталогах
Клик по QR открывает крупный просмотр с подписью; закрытие по Esc,
кнопке и клику на фон. В global и server index.
2026-07-23 09:07:23 +03:00
inecs 345326d844 Обновить bundled install_server.sh с официального get.hy2.sh
Синхронизация локальной копии после проверки SHA256.
2026-07-23 09:02:06 +03:00
inecs 9bfbd07251 Добавить make index: пересборка output/index.html из локальных экспортов
Отдельный playbook на localhost читает server-info.yml, обновляет
общий каталог и открывает его в браузере без обращения к VPS.
2026-07-23 08:56:04 +03:00
inecs dc35bec4fb fix: корректный make status при inactive и без -o
systemctl is-active с rc≠0 больше не дублирует inactive;
убран deprecated флаг -o.
2026-07-23 08:46:17 +03:00
inecs e11ccd3040 Улучшить вывод make status: кратко и без логов journal
Одна строка на хост (active/not installed, since, PID, RAM);
хост без установки больше не валит весь make.
2026-07-23 08:32:08 +03:00
inecs 2210a55782 fix: пропускать sync install_server.sh при сетевой ошибке
Если get.hy2.sh недоступен (таймаут без VPN), используется локальная
копия в roles/hysteria2/files/; падение только если её нет.
2026-07-01 14:06:46 +03:00
inecs 1452086c98 docs: LIMIT при добавлении сервера и force_export в README
Описано, что make install LIMIT не трогает старые VPS и как принудительно
перевыпустить URL/QR через hysteria2_force_export.
2026-07-01 13:37:53 +03:00
inecs 9600e18f65 fix: пересборка output/index.html при uninstall с LIMIT
Второй play перенесён на hysteria2_servers с run_once на localhost,
чтобы LIMIT не пропускал пересборку. Пустой index.html удаляется.
2026-07-01 13:31:28 +03:00
inecs 2331a5c04e Полная очистка при uninstall: VPS и output/<server>/
Скрипт --remove снимает только бинарник и systemd; Ansible дочищает конфиг,
пользователя, ufw, пакеты и всегда удаляет локальную папку экспорта.
2026-07-01 13:28:59 +03:00
inecs da66d4e045 fix: run only uninstall tasks, not full role on make uninstall
Tagging the whole role with uninstall caused install/configure/update/export to run first. Use include_role tasks_from uninstall.yml; add safe defaults for uninstall variables.
2026-07-01 13:20:17 +03:00
inecs 90db85aaf0 docs: update README for Salamander branch features
Mirror main documentation: install script sync, incremental update/export, uninstall, vault wiring, Makefile and role structure.
2026-07-01 13:16:38 +03:00
inecs ca22c0f39d feat: incremental user passwords and export on update
Preserve passwords from server-info.yml and vault; generate only for new users; remove deleted users from output; re-export URL/QR only when password, domain, port, obfs or files changed.
2026-07-01 13:06:33 +03:00
inecs fc5efccd91 feat: complete uninstall cleanup on server and local output
Remove binary, config, masq, system user, ufw rules, and apt packages from VPS; delete output/<server>/ by default and rebuild global index.html via localhost play.
2026-07-01 12:56:20 +03:00
inecs f3bd4313df feat: bundle and sync official Hysteria2 install script locally
Store install_server.sh in role files, compare SHA256 with get.hy2.sh on the control node before install/update, refresh the bundled copy when upstream changes, then copy and run it on VPS.
2026-07-01 12:00:37 +03:00
inecs a821dabf18 fix: disable become on localhost play and drop deprecated facts
Use now() for export timestamps and ansible_facts['system'] for browser detection. Set become: false on the localhost play to avoid sudo on local fact gathering.
2026-07-01 11:56:18 +03:00
inecs 69eccce8eb fix: generate VPN passwords without pwgen, set EDITOR=nano
Use Ansible password lookup on the control node so install works before packages are installed on VPS and without pwgen on macOS. Export EDITOR=nano in Makefile for vault-edit.
2026-07-01 11:43:34 +03:00
inecs d4f968d799 fix: replace removed yaml callback with default + result_format
community.general.yaml was removed in collection 12.0; use ansible.builtin.default with result_format=yaml for Ansible 2.13+.
2026-07-01 11:09:08 +03:00
inecs 5ed370013b Align README with main branch: branches comparison, ACME renewal, switching.
Unified documentation structure across main and salamander branches.
2026-07-01 02:22:28 +03:00
inecs c9b283eca6 Add Salamander obfs branch: replace masquerade with packet obfuscation.
- ACME TLS challenge on 443 (no port 80 or nginx decoy)
- Auto-generate and persist obfs password per server
- Update client export, HTML catalog, and vault examples
- Document Salamander vs main and ACME auto-renewal in README
2026-07-01 02:17:22 +03:00
inecs df19816039 Initial commit: Ansible role for Hysteria2 VPN server deployment.
Includes install/update/uninstall playbooks, Makefile, vault-based SSH credentials, per-server and global HTML export with QR codes.
2026-07-01 02:02:58 +03:00
3 changed files with 335 additions and 27 deletions
+14
View File
@@ -8,6 +8,20 @@ Ansible-роль для установки [Hysteria 2](https://v2.hysteria.netw
---
## Последние обновления
### 2026-07-23
- **`make status`** — краткий статус по хостам (active / not installed, PID, RAM), без длинных логов journal.
- **`make index`** — пересборка HTML всех серверов и общего `output/index.html` из локальных `server-info.yml` (без SSH), открытие в браузере.
- **Каталог `output/index.html`** — вкладки «Статистика» (по умолчанию) и серверы (`vps-db`, `vps-kz`, …): IP, доступность TCP, пользователи, матрица присутствия.
- **Страницы серверов** — сетка карточек и визуальный стиль как у главной; ссылка «← Общий каталог».
- **Lightbox QR** — крупный просмотр на высоту экрана, кнопка скачивания `<сервер>-<пользователь>.png`.
- **Скачивание файлов** — ссылки `.url` / `.txt` / `.png` / `.qr.txt` принудительно скачивают файл как `<сервер>-<пользователь>.ext`, а не открывают его во вкладке браузера.
- **`make install` / `make update` / `make export`** — в конце тот же pipeline HTML, что и у `make index`.
---
## Выбор ветки: `main` или `salamander`
| | **`main`** | **`salamander` (эта ветка)** |
@@ -516,8 +516,10 @@
background: var(--bg-input);
color: var(--accent);
text-decoration: none;
font: inherit;
font-size: 0.85rem;
font-weight: 650;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.lightbox-download:hover {
@@ -625,7 +627,7 @@
</div>
<div class="meta-item">
<label>Режим</label>
<span>{{ server.mode | default('salamander') | e }}</span>
<span>{{ server.mode | default('masquerade') | e }}</span>
</div>
<div class="meta-item">
<label>Пользователи</label>
@@ -716,16 +718,19 @@
</div>
</div>
{% 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 %}
<div class="qr-block">
<img
src="{{ server.dir | e }}/{{ user.name | e }}.png"
src="{{ _png.data }}"
alt="QR {{ user.name | e }}"
width="160"
height="160"
role="button"
tabindex="0"
data-lightbox-src="{{ server.dir | e }}/{{ user.name | e }}.png"
data-lightbox-src="{{ _png.data }}"
data-lightbox-caption="{{ server.name | e }} · {{ user.name | e }}"
data-lightbox-download="{{ server.name | e }}-{{ user.name | e }}.png"
aria-label="Открыть QR-код {{ user.name | e }}"
@@ -734,12 +739,12 @@
{% endif %}
<nav class="files" aria-label="Файлы">
<a href="{{ server.dir | e }}/{{ user.name | e }}.url" download>.url</a>
<a href="{{ server.dir | e }}/{{ user.name | e }}.txt" download>.txt</a>
{% if user.has_png | default(false) %}
<a href="{{ server.dir | e }}/{{ user.name | e }}.png" download>.png</a>
<a class="file-download" href="{{ server.dir | e }}/{{ user.name | e }}.url" download="{{ server.name | e }}-{{ user.name | e }}.url" data-filename="{{ server.name | e }}-{{ user.name | e }}.url" data-text="{{ (user.url ~ '\n') | e }}">.url</a>
<a class="file-download" href="{{ server.dir | e }}/{{ user.name | e }}.txt" download="{{ server.name | e }}-{{ user.name | e }}.txt" data-filename="{{ server.name | e }}-{{ user.name | e }}.txt" data-text="{{ ('Server: ' ~ server.name ~ '\nDomain: ' ~ server.domain ~ ':' ~ server.port ~ '\nUser: ' ~ user.name ~ '\nPassword: ' ~ user.password ~ '\n\nURL:\n' ~ user.url ~ '\n') | e }}">.txt</a>
{% if user.has_png | default(false) and _png.b64 %}
<a class="file-download" href="{{ server.dir | e }}/{{ user.name | e }}.png" download="{{ server.name | e }}-{{ user.name | e }}.png" data-filename="{{ server.name | e }}-{{ user.name | e }}.png" data-png-b64="{{ _png.b64 }}">.png</a>
{% endif %}
<a href="{{ server.dir | e }}/{{ user.name | e }}.qr.txt" download>.qr.txt</a>
<a class="file-download" href="{{ server.dir | e }}/{{ user.name | e }}.qr.txt" download="{{ server.name | e }}-{{ user.name | e }}.qr.txt" data-filename="{{ server.name | e }}-{{ user.name | e }}.qr.txt" data-text="{{ lookup('file', hysteria2_output_dir ~ '/' ~ server.dir ~ '/' ~ user.name ~ '.qr.txt', errors='ignore') | default('URL: ' ~ user.url ~ '\n', true) | e }}">.qr.txt</a>
</nav>
</article>
{% endfor %}
@@ -759,10 +764,10 @@
</div>
<p class="lightbox-caption" id="lightbox-caption"></p>
<div class="lightbox-actions">
<a class="lightbox-download" id="lightbox-download" href="#" download>
<button type="button" class="lightbox-download" id="lightbox-download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Скачать QR
</a>
</button>
</div>
</div>
</div>
@@ -825,14 +830,76 @@
var caption = document.getElementById('lightbox-caption');
var closeBtn = document.getElementById('lightbox-close');
var downloadBtn = document.getElementById('lightbox-download');
var downloadSrc = '';
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
}
function downloadQrFromCanvas(filename) {
if (!img.complete || !img.naturalWidth) return false;
try {
var canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
// toDataURL sync — download stays in the user-gesture (Safari/Chrome)
triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
return true;
} catch (err) {
return false;
}
}
function downloadQrImage(e) {
e.preventDefault();
e.stopPropagation();
var filename = downloadBtn.getAttribute('data-filename') || 'qr.png';
var src = downloadSrc || img.currentSrc || img.src;
// 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');
return res.blob();
})
.then(function (blob) { triggerFileDownload(blob, filename); })
.catch(function () { /* avoid navigating to the image URL */ });
}
function openLightbox(src, text, alt, filename) {
var safeName = filename || 'qr.png';
img.src = src;
img.alt = alt || text || 'QR';
caption.textContent = text || '';
downloadBtn.href = src;
downloadBtn.setAttribute('download', filename || 'qr.png');
downloadBtn.setAttribute('aria-label', 'Скачать ' + (filename || 'qr.png'));
downloadSrc = src;
downloadBtn.setAttribute('data-filename', safeName);
downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName);
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
document.body.classList.add('lightbox-open');
@@ -844,7 +911,8 @@
box.setAttribute('aria-hidden', 'true');
document.body.classList.remove('lightbox-open');
img.removeAttribute('src');
downloadBtn.removeAttribute('href');
downloadSrc = '';
downloadBtn.removeAttribute('data-filename');
}
document.querySelectorAll('[data-lightbox-src]').forEach(function (el) {
@@ -865,6 +933,7 @@
});
});
downloadBtn.addEventListener('click', downloadQrImage);
closeBtn.addEventListener('click', closeLightbox);
box.addEventListener('click', function (e) {
if (e.target === box) closeLightbox();
@@ -873,6 +942,84 @@
if (e.key === 'Escape' && box.classList.contains('open')) closeLightbox();
});
})();
(function () {
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
}
function pngFromCard(link, filename) {
var card = link.closest('.user-card');
var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img');
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;
canvas.height = qrImg.naturalHeight;
canvas.getContext('2d').drawImage(qrImg, 0, 0);
triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
return true;
} catch (err) {
return false;
}
}
document.querySelectorAll('a.file-download').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
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 — 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 || location.protocol === 'file:') return;
fetch(href)
.then(function (res) {
if (!res.ok) throw new Error('fetch failed');
return res.blob();
})
.then(function (blob) { triggerFileDownload(blob, filename); })
.catch(function () { /* do not navigate to the file URL */ });
});
});
})();
</script>
</body>
</html>
+160 -13
View File
@@ -333,8 +333,10 @@
background: var(--bg-input);
color: var(--accent);
text-decoration: none;
font: inherit;
font-size: 0.85rem;
font-weight: 650;
cursor: pointer;
}
.lightbox-download:hover {
border-color: var(--accent);
@@ -422,16 +424,19 @@
</div>
</div>
{% 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 %}
<div class="qr-block">
<img
src="{{ user.name | e }}.png"
src="{{ _png.data }}"
alt="QR {{ user.name | e }}"
width="160"
height="160"
role="button"
tabindex="0"
data-lightbox-src="{{ user.name | e }}.png"
data-lightbox-src="{{ _png.data }}"
data-lightbox-caption="{{ hysteria2_output_name | e }} · {{ user.name | e }}"
data-lightbox-download="{{ hysteria2_output_name | e }}-{{ user.name | e }}.png"
aria-label="Открыть QR-код {{ user.name | e }}"
@@ -440,12 +445,12 @@
{% endif %}
<nav class="files" aria-label="Файлы">
<a href="{{ user.name | e }}.url" download>.url</a>
<a href="{{ user.name | e }}.txt" download>.txt</a>
{% if user.has_png | default(false) %}
<a href="{{ user.name | e }}.png" download>.png</a>
<a class="file-download" href="{{ user.name | e }}.url" download="{{ hysteria2_output_name | e }}-{{ user.name | e }}.url" data-filename="{{ hysteria2_output_name | e }}-{{ user.name | e }}.url" data-text="{{ (user.url ~ '\n') | e }}">.url</a>
<a class="file-download" href="{{ user.name | e }}.txt" download="{{ hysteria2_output_name | e }}-{{ user.name | e }}.txt" data-filename="{{ hysteria2_output_name | e }}-{{ user.name | e }}.txt" data-text="{{ ('Server: ' ~ hysteria2_output_name ~ '\nDomain: ' ~ hysteria2_domain ~ ':' ~ hysteria2_listen_port ~ '\nUser: ' ~ user.name ~ '\nPassword: ' ~ user.password ~ '\n\nURL:\n' ~ user.url ~ '\n') | e }}">.txt</a>
{% if user.has_png | default(false) and _png.b64 %}
<a class="file-download" href="{{ user.name | e }}.png" download="{{ hysteria2_output_name | e }}-{{ user.name | e }}.png" data-filename="{{ hysteria2_output_name | e }}-{{ user.name | e }}.png" data-png-b64="{{ _png.b64 }}">.png</a>
{% endif %}
<a href="{{ user.name | e }}.qr.txt" download>.qr.txt</a>
<a class="file-download" href="{{ user.name | e }}.qr.txt" download="{{ hysteria2_output_name | e }}-{{ user.name | e }}.qr.txt" data-filename="{{ hysteria2_output_name | e }}-{{ user.name | e }}.qr.txt" data-text="{{ lookup('file', hysteria2_output_dir ~ '/' ~ hysteria2_output_name ~ '/' ~ user.name ~ '.qr.txt', errors='ignore') | default('URL: ' ~ user.url ~ '\n', true) | e }}">.qr.txt</a>
</nav>
</article>
{% endfor %}
@@ -463,10 +468,10 @@
</div>
<p class="lightbox-caption" id="lightbox-caption"></p>
<div class="lightbox-actions">
<a class="lightbox-download" id="lightbox-download" href="#" download>
<button type="button" class="lightbox-download" id="lightbox-download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Скачать QR
</a>
</button>
</div>
</div>
</div>
@@ -502,14 +507,76 @@
var caption = document.getElementById('lightbox-caption');
var closeBtn = document.getElementById('lightbox-close');
var downloadBtn = document.getElementById('lightbox-download');
var downloadSrc = '';
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
}
function downloadQrFromCanvas(filename) {
if (!img.complete || !img.naturalWidth) return false;
try {
var canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
// toDataURL sync — download stays in the user-gesture (Safari/Chrome)
triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
return true;
} catch (err) {
return false;
}
}
function downloadQrImage(e) {
e.preventDefault();
e.stopPropagation();
var filename = downloadBtn.getAttribute('data-filename') || 'qr.png';
var src = downloadSrc || img.currentSrc || img.src;
// 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');
return res.blob();
})
.then(function (blob) { triggerFileDownload(blob, filename); })
.catch(function () { /* avoid navigating to the image URL */ });
}
function openLightbox(src, text, alt, filename) {
var safeName = filename || 'qr.png';
img.src = src;
img.alt = alt || text || 'QR';
caption.textContent = text || '';
downloadBtn.href = src;
downloadBtn.setAttribute('download', filename || 'qr.png');
downloadBtn.setAttribute('aria-label', 'Скачать ' + (filename || 'qr.png'));
downloadSrc = src;
downloadBtn.setAttribute('data-filename', safeName);
downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName);
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
document.body.classList.add('lightbox-open');
@@ -521,7 +588,8 @@
box.setAttribute('aria-hidden', 'true');
document.body.classList.remove('lightbox-open');
img.removeAttribute('src');
downloadBtn.removeAttribute('href');
downloadSrc = '';
downloadBtn.removeAttribute('data-filename');
}
document.querySelectorAll('[data-lightbox-src]').forEach(function (el) {
@@ -542,6 +610,7 @@
});
});
downloadBtn.addEventListener('click', downloadQrImage);
closeBtn.addEventListener('click', closeLightbox);
box.addEventListener('click', function (e) {
if (e.target === box) closeLightbox();
@@ -550,6 +619,84 @@
if (e.key === 'Escape' && box.classList.contains('open')) closeLightbox();
});
})();
(function () {
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
}
function pngFromCard(link, filename) {
var card = link.closest('.user-card');
var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img');
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;
canvas.height = qrImg.naturalHeight;
canvas.getContext('2d').drawImage(qrImg, 0, 0);
triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
return true;
} catch (err) {
return false;
}
}
document.querySelectorAll('a.file-download').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
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 — 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 || location.protocol === 'file:') return;
fetch(href)
.then(function (res) {
if (!res.ok) throw new Error('fetch failed');
return res.blob();
})
.then(function (blob) { triggerFileDownload(blob, filename); })
.catch(function () { /* do not navigate to the file URL */ });
});
});
})();
</script>
</body>
</html>