Compare commits

..

30 Commits

Author SHA1 Message Date
Sergey Antropoff 7fddebc810 make index и install/update: единый стиль страниц серверов и каталога
Пересборка HTML серверов из server-info.yml; сетка карточек и вид
как у главной. Тот же pipeline после install/update/export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:35:47 +03:00
Sergey Antropoff f745b8ecfd Убрать подсказку закрытия из lightbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:31:20 +03:00
Sergey Antropoff 1aa711f65a Сетка карточек на странице сервера и крупный QR в lightbox
Карточки как в глобальном индексе; QR заполняет доступную
площадь модального окна через container queries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:25:22 +03:00
Sergey Antropoff ade9fbba15 docs: упомянуть вкладки и статистику в make index
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:21:24 +03:00
Sergey Antropoff 441652b079 Вкладки серверов и страница статистики в output/index.html
По умолчанию — Статистика: доступность TCP, IP, пользователи,
матрица присутствия. Серверы открываются отдельными вкладками.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:21:04 +03:00
Sergey Antropoff ad23e81a43 Сделать lightbox на всю высоту экрана с небольшими отступами
Панель занимает 100dvh минус 1rem сверху/снизу; QR растягивается
в оставшееся пространство.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:16:45 +03:00
Sergey Antropoff fb23d48b64 Увеличить QR в lightbox на 30% (до ~728px)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:13:58 +03:00
Sergey Antropoff 16fa1b46ef Ограничить lightbox QR высотой экрана с отступами сверху/снизу
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:13:00 +03:00
Sergey Antropoff 93f20ffca8 Увеличить QR в lightbox до ~560px
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:11:14 +03:00
Sergey Antropoff 4e9a902b35 Добавить кнопку скачивания QR в lightbox
Файл сохраняется как <сервер>-<пользователь>.png.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:09:12 +03:00
Sergey Antropoff db17f1860c Добавить lightbox для QR-кодов в HTML-каталогах
Клик по QR открывает крупный просмотр с подписью; закрытие по Esc,
кнопке и клику на фон. В global и server index.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:07:23 +03:00
Sergey Antropoff d4dc25cdd3 Обновить bundled install_server.sh с официального get.hy2.sh
Синхронизация локальной копии после проверки SHA256.
2026-07-23 09:02:06 +03:00
Sergey Antropoff f5f2384252 Добавить make index: пересборка output/index.html из локальных экспортов
Отдельный playbook на localhost читает server-info.yml, обновляет
общий каталог и открывает его в браузере без обращения к VPS.
2026-07-23 08:56:04 +03:00
Sergey Antropoff 100e3cee12 fix: корректный make status при inactive и без -o
systemctl is-active с rc≠0 больше не дублирует inactive;
убран deprecated флаг -o.
2026-07-23 08:46:17 +03:00
Sergey Antropoff 062512121d Улучшить вывод make status: кратко и без логов journal
Одна строка на хост (active/not installed, since, PID, RAM);
хост без установки больше не валит весь make.
2026-07-23 08:32:08 +03:00
Sergey Antropoff 8ba1c6c45f fix: пропускать sync install_server.sh при сетевой ошибке
Если get.hy2.sh недоступен (таймаут без VPN), используется локальная
копия в roles/hysteria2/files/; падение только если её нет.
2026-07-01 14:06:46 +03:00
Sergey Antropoff 3034be388b docs: LIMIT при добавлении сервера и force_export в README
Описано, что make install LIMIT не трогает старые VPS и как принудительно
перевыпустить URL/QR через hysteria2_force_export.
2026-07-01 13:37:53 +03:00
Sergey Antropoff 83b47dbc30 fix: пересборка output/index.html при uninstall с LIMIT
Второй play перенесён на hysteria2_servers с run_once на localhost,
чтобы LIMIT не пропускал пересборку. Пустой index.html удаляется.
2026-07-01 13:31:28 +03:00
Sergey Antropoff 4b5a1ef51f Полная очистка при uninstall: VPS и output/<server>/
Скрипт --remove снимает только бинарник и systemd; Ansible дочищает конфиг,
пользователя, ufw, пакеты и всегда удаляет локальную папку экспорта.
2026-07-01 13:28:59 +03:00
Sergey Antropoff bdc316efac 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
Sergey Antropoff 01198b4f77 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
Sergey Antropoff 0745fdce2c 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
Sergey Antropoff 5e42d60778 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
Sergey Antropoff b9e622e5c2 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
Sergey Antropoff 2f88108a8d 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
Sergey Antropoff 4242093ca1 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
Sergey Antropoff e3d3103dc2 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
Sergey Antropoff 63b21d5df6 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
Sergey Antropoff 0aec9e6e54 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
Sergey Antropoff 6f96a26bed 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 27 additions and 335 deletions
-14
View File
@@ -8,20 +8,6 @@ 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`
| | **`main`** | **`salamander` (эта ветка)** | | | **`main`** | **`salamander` (эта ветка)** |
@@ -516,10 +516,8 @@
background: var(--bg-input); background: var(--bg-input);
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 650; font-weight: 650;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s; transition: color 0.15s, border-color 0.15s, background 0.15s;
} }
.lightbox-download:hover { .lightbox-download:hover {
@@ -627,7 +625,7 @@
</div> </div>
<div class="meta-item"> <div class="meta-item">
<label>Режим</label> <label>Режим</label>
<span>{{ server.mode | default('masquerade') | e }}</span> <span>{{ server.mode | default('salamander') | e }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<label>Пользователи</label> <label>Пользователи</label>
@@ -718,19 +716,16 @@
</div> </div>
</div> </div>
{% set _png = namespace(b64='', data='') %}
{% if user.has_png | default(false) %} {% 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"> <div class="qr-block">
<img <img
src="{{ _png.data }}" src="{{ server.dir | e }}/{{ user.name | e }}.png"
alt="QR {{ user.name | e }}" alt="QR {{ user.name | e }}"
width="160" width="160"
height="160" height="160"
role="button" role="button"
tabindex="0" tabindex="0"
data-lightbox-src="{{ _png.data }}" data-lightbox-src="{{ server.dir | e }}/{{ user.name | e }}.png"
data-lightbox-caption="{{ server.name | e }} · {{ user.name | e }}" data-lightbox-caption="{{ server.name | e }} · {{ user.name | e }}"
data-lightbox-download="{{ server.name | e }}-{{ user.name | e }}.png" data-lightbox-download="{{ server.name | e }}-{{ user.name | e }}.png"
aria-label="Открыть QR-код {{ user.name | e }}" aria-label="Открыть QR-код {{ user.name | e }}"
@@ -739,12 +734,12 @@
{% endif %} {% endif %}
<nav class="files" aria-label="Файлы"> <nav class="files" aria-label="Файлы">
<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 href="{{ server.dir | e }}/{{ user.name | e }}.url" download>.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> <a href="{{ server.dir | e }}/{{ user.name | e }}.txt" download>.txt</a>
{% if user.has_png | default(false) and _png.b64 %} {% if user.has_png | default(false) %}
<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> <a href="{{ server.dir | e }}/{{ user.name | e }}.png" download>.png</a>
{% endif %} {% endif %}
<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> <a href="{{ server.dir | e }}/{{ user.name | e }}.qr.txt" download>.qr.txt</a>
</nav> </nav>
</article> </article>
{% endfor %} {% endfor %}
@@ -764,10 +759,10 @@
</div> </div>
<p class="lightbox-caption" id="lightbox-caption"></p> <p class="lightbox-caption" id="lightbox-caption"></p>
<div class="lightbox-actions"> <div class="lightbox-actions">
<button type="button" class="lightbox-download" id="lightbox-download"> <a class="lightbox-download" id="lightbox-download" href="#" 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> <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 Скачать QR
</button> </a>
</div> </div>
</div> </div>
</div> </div>
@@ -830,76 +825,14 @@
var caption = document.getElementById('lightbox-caption'); var caption = document.getElementById('lightbox-caption');
var closeBtn = document.getElementById('lightbox-close'); var closeBtn = document.getElementById('lightbox-close');
var downloadBtn = document.getElementById('lightbox-download'); 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) { function openLightbox(src, text, alt, filename) {
var safeName = filename || 'qr.png';
img.src = src; img.src = src;
img.alt = alt || text || 'QR'; img.alt = alt || text || 'QR';
caption.textContent = text || ''; caption.textContent = text || '';
downloadSrc = src; downloadBtn.href = src;
downloadBtn.setAttribute('data-filename', safeName); downloadBtn.setAttribute('download', filename || 'qr.png');
downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName); downloadBtn.setAttribute('aria-label', 'Скачать ' + (filename || 'qr.png'));
box.classList.add('open'); box.classList.add('open');
box.setAttribute('aria-hidden', 'false'); box.setAttribute('aria-hidden', 'false');
document.body.classList.add('lightbox-open'); document.body.classList.add('lightbox-open');
@@ -911,8 +844,7 @@
box.setAttribute('aria-hidden', 'true'); box.setAttribute('aria-hidden', 'true');
document.body.classList.remove('lightbox-open'); document.body.classList.remove('lightbox-open');
img.removeAttribute('src'); img.removeAttribute('src');
downloadSrc = ''; downloadBtn.removeAttribute('href');
downloadBtn.removeAttribute('data-filename');
} }
document.querySelectorAll('[data-lightbox-src]').forEach(function (el) { document.querySelectorAll('[data-lightbox-src]').forEach(function (el) {
@@ -933,7 +865,6 @@
}); });
}); });
downloadBtn.addEventListener('click', downloadQrImage);
closeBtn.addEventListener('click', closeLightbox); closeBtn.addEventListener('click', closeLightbox);
box.addEventListener('click', function (e) { box.addEventListener('click', function (e) {
if (e.target === box) closeLightbox(); if (e.target === box) closeLightbox();
@@ -942,84 +873,6 @@
if (e.key === 'Escape' && box.classList.contains('open')) closeLightbox(); 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> </script>
</body> </body>
</html> </html>
+13 -160
View File
@@ -333,10 +333,8 @@
background: var(--bg-input); background: var(--bg-input);
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 650; font-weight: 650;
cursor: pointer;
} }
.lightbox-download:hover { .lightbox-download:hover {
border-color: var(--accent); border-color: var(--accent);
@@ -424,19 +422,16 @@
</div> </div>
</div> </div>
{% set _png = namespace(b64='', data='') %}
{% if user.has_png | default(false) %} {% 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"> <div class="qr-block">
<img <img
src="{{ _png.data }}" src="{{ user.name | e }}.png"
alt="QR {{ user.name | e }}" alt="QR {{ user.name | e }}"
width="160" width="160"
height="160" height="160"
role="button" role="button"
tabindex="0" tabindex="0"
data-lightbox-src="{{ _png.data }}" data-lightbox-src="{{ user.name | e }}.png"
data-lightbox-caption="{{ hysteria2_output_name | e }} · {{ user.name | e }}" data-lightbox-caption="{{ hysteria2_output_name | e }} · {{ user.name | e }}"
data-lightbox-download="{{ hysteria2_output_name | e }}-{{ user.name | e }}.png" data-lightbox-download="{{ hysteria2_output_name | e }}-{{ user.name | e }}.png"
aria-label="Открыть QR-код {{ user.name | e }}" aria-label="Открыть QR-код {{ user.name | e }}"
@@ -445,12 +440,12 @@
{% endif %} {% endif %}
<nav class="files" aria-label="Файлы"> <nav class="files" aria-label="Файлы">
<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 href="{{ user.name | e }}.url" download>.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> <a href="{{ user.name | e }}.txt" download>.txt</a>
{% if user.has_png | default(false) and _png.b64 %} {% if user.has_png | default(false) %}
<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> <a href="{{ user.name | e }}.png" download>.png</a>
{% endif %} {% endif %}
<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> <a href="{{ user.name | e }}.qr.txt" download>.qr.txt</a>
</nav> </nav>
</article> </article>
{% endfor %} {% endfor %}
@@ -468,10 +463,10 @@
</div> </div>
<p class="lightbox-caption" id="lightbox-caption"></p> <p class="lightbox-caption" id="lightbox-caption"></p>
<div class="lightbox-actions"> <div class="lightbox-actions">
<button type="button" class="lightbox-download" id="lightbox-download"> <a class="lightbox-download" id="lightbox-download" href="#" 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> <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 Скачать QR
</button> </a>
</div> </div>
</div> </div>
</div> </div>
@@ -507,76 +502,14 @@
var caption = document.getElementById('lightbox-caption'); var caption = document.getElementById('lightbox-caption');
var closeBtn = document.getElementById('lightbox-close'); var closeBtn = document.getElementById('lightbox-close');
var downloadBtn = document.getElementById('lightbox-download'); 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) { function openLightbox(src, text, alt, filename) {
var safeName = filename || 'qr.png';
img.src = src; img.src = src;
img.alt = alt || text || 'QR'; img.alt = alt || text || 'QR';
caption.textContent = text || ''; caption.textContent = text || '';
downloadSrc = src; downloadBtn.href = src;
downloadBtn.setAttribute('data-filename', safeName); downloadBtn.setAttribute('download', filename || 'qr.png');
downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName); downloadBtn.setAttribute('aria-label', 'Скачать ' + (filename || 'qr.png'));
box.classList.add('open'); box.classList.add('open');
box.setAttribute('aria-hidden', 'false'); box.setAttribute('aria-hidden', 'false');
document.body.classList.add('lightbox-open'); document.body.classList.add('lightbox-open');
@@ -588,8 +521,7 @@
box.setAttribute('aria-hidden', 'true'); box.setAttribute('aria-hidden', 'true');
document.body.classList.remove('lightbox-open'); document.body.classList.remove('lightbox-open');
img.removeAttribute('src'); img.removeAttribute('src');
downloadSrc = ''; downloadBtn.removeAttribute('href');
downloadBtn.removeAttribute('data-filename');
} }
document.querySelectorAll('[data-lightbox-src]').forEach(function (el) { document.querySelectorAll('[data-lightbox-src]').forEach(function (el) {
@@ -610,7 +542,6 @@
}); });
}); });
downloadBtn.addEventListener('click', downloadQrImage);
closeBtn.addEventListener('click', closeLightbox); closeBtn.addEventListener('click', closeLightbox);
box.addEventListener('click', function (e) { box.addEventListener('click', function (e) {
if (e.target === box) closeLightbox(); if (e.target === box) closeLightbox();
@@ -619,84 +550,6 @@
if (e.key === 'Escape' && box.classList.contains('open')) closeLightbox(); 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> </script>
</body> </body>
</html> </html>