mirror of
https://github.com/andrey271192/PCAtelegram_web.git
synced 2026-09-20 11:55:32 +00:00
feat: add Mieru subscription QR
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
- Added README docs for custom domain and public site flow.
|
- Added README docs for custom domain and public site flow.
|
||||||
- Added Mieru / mita management in web-admin with install, port validation, client JSON, mihomo YAML, logs, services, and backup support.
|
- Added Mieru / mita management in web-admin with install, port validation, client JSON, mihomo YAML, logs, services, and backup support.
|
||||||
- Added tokenized Mieru mihomo subscription URL and Clash import URL for iOS clients.
|
- Added tokenized Mieru mihomo subscription URL and Clash import URL for iOS clients.
|
||||||
|
- Added QR code for Mieru subscription URL.
|
||||||
|
|
||||||
## 2.5.0
|
## 2.5.0
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ Per-client runtime routing в текущем telemt не включается а
|
|||||||
|
|
||||||
В web-admin Settings есть блок `Mieru`:
|
В web-admin Settings есть блок `Mieru`:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
- `Mieru port` — отдельный публичный порт `mita`, по умолчанию `2999`. Диапазон Mieru: `1025-65535`.
|
- `Mieru port` — отдельный публичный порт `mita`, по умолчанию `2999`. Диапазон Mieru: `1025-65535`.
|
||||||
- `Transport` — `TCP` или `UDP`.
|
- `Transport` — `TCP` или `UDP`.
|
||||||
- `User` / `Password` — учётка Mieru. Если пароль пустой, web-admin генерирует новый.
|
- `User` / `Password` — учётка Mieru. Если пароль пустой, web-admin генерирует новый.
|
||||||
@@ -137,6 +141,7 @@ Per-client runtime routing в текущем telemt не включается а
|
|||||||
- `Client JSON` — готовый конфиг для официального `mieru` client.
|
- `Client JSON` — готовый конфиг для официального `mieru` client.
|
||||||
- `mihomo YAML` — proxy block для клиентов с поддержкой `type: mieru`.
|
- `mihomo YAML` — proxy block для клиентов с поддержкой `type: mieru`.
|
||||||
- `mihomo subscription URL` — ссылка вида `http://SERVER:1984/sub/mieru/<token>.yaml`. Её нужно вставлять в iOS Clash/mihomo apps как subscription/profile URL.
|
- `mihomo subscription URL` — ссылка вида `http://SERVER:1984/sub/mieru/<token>.yaml`. Её нужно вставлять в iOS Clash/mihomo apps как subscription/profile URL.
|
||||||
|
- `QR subscription` — QR-код этого subscription URL для импорта с телефона.
|
||||||
- `Clash import URL` — deep link `clash://install-config?...` для клиентов, которые умеют открывать Clash import links.
|
- `Clash import URL` — deep link `clash://install-config?...` для клиентов, которые умеют открывать Clash import links.
|
||||||
|
|
||||||
Не вставляйте сырой `mihomo YAML` в поле URL: iOS clients ожидают ссылку, иначе будет ошибка `No host specified in URI proxies:`. YAML нужен только для ручной вставки в config file.
|
Не вставляйте сырой `mihomo YAML` в поле URL: iOS clients ожидают ссылку, иначе будет ошибка `No host specified in URI proxies:`. YAML нужен только для ручной вставки в config file.
|
||||||
|
|||||||
@@ -2432,16 +2432,31 @@ def launch_restore_backup(name: str, password: str = "") -> dict[str, Any]:
|
|||||||
return {"name": backup_path.name, "started": True, "log": str(BACKUP_RESTORE_LOG)}
|
return {"name": backup_path.name, "started": True, "log": str(BACKUP_RESTORE_LOG)}
|
||||||
|
|
||||||
|
|
||||||
|
def qr_png_for_text(value: str) -> bytes:
|
||||||
|
code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", value], timeout=8)
|
||||||
|
if code != 0 or not image:
|
||||||
|
raise RuntimeError(error.strip() or "qrencode is not installed")
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
def user_qr_png(name: str) -> tuple[bytes, str]:
|
def user_qr_png(name: str) -> tuple[bytes, str]:
|
||||||
users = read_user_records()
|
users = read_user_records()
|
||||||
record = users.get(name)
|
record = users.get(name)
|
||||||
if not record:
|
if not record:
|
||||||
raise FileNotFoundError("user not found")
|
raise FileNotFoundError("user not found")
|
||||||
link = proxy_link(str(record.get("secret", "")))
|
link = proxy_link(str(record.get("secret", "")))
|
||||||
code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", link], timeout=8)
|
return qr_png_for_text(link), link
|
||||||
if code != 0 or not image:
|
|
||||||
raise RuntimeError(error.strip() or "qrencode is not installed")
|
|
||||||
return image, link
|
def mieru_subscription_qr_png() -> tuple[bytes, str]:
|
||||||
|
cfg = read_mieru_config()
|
||||||
|
if not cfg.get("password"):
|
||||||
|
raise FileNotFoundError("Mieru is not configured")
|
||||||
|
cfg, _ = ensure_mieru_subscription_token(cfg)
|
||||||
|
link = mieru_subscription_url(cfg)
|
||||||
|
if not link:
|
||||||
|
raise FileNotFoundError("Mieru subscription URL is not configured")
|
||||||
|
return qr_png_for_text(link), link
|
||||||
|
|
||||||
|
|
||||||
def read_log_payload(service: str) -> dict[str, Any]:
|
def read_log_payload(service: str) -> dict[str, Any]:
|
||||||
@@ -2713,6 +2728,23 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"ok": True, "data": public_warp_config()})
|
self.send_json({"ok": True, "data": public_warp_config()})
|
||||||
elif path == "/api/mieru":
|
elif path == "/api/mieru":
|
||||||
self.send_json({"ok": True, "data": public_mieru_config()})
|
self.send_json({"ok": True, "data": public_mieru_config()})
|
||||||
|
elif path == "/api/mieru/qr":
|
||||||
|
try:
|
||||||
|
png, link = mieru_subscription_qr_png()
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.send_error_json(404, "Mieru subscription is not configured")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self.send_error_json(503, str(exc))
|
||||||
|
return
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_security_headers()
|
||||||
|
self.send_header("Content-Type", "image/png")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Subscription-Link", urllib.parse.quote(link, safe=""))
|
||||||
|
self.send_header("Content-Length", str(len(png)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(png)
|
||||||
elif path == "/api/users":
|
elif path == "/api/users":
|
||||||
users = read_user_records()
|
users = read_user_records()
|
||||||
latest = latest_user_stats()
|
latest = latest_user_stats()
|
||||||
|
|||||||
@@ -151,7 +151,9 @@ const i18n = {
|
|||||||
mieruSubscriptionUrl: "mihomo subscription URL",
|
mieruSubscriptionUrl: "mihomo subscription URL",
|
||||||
mieruClashImportUrl: "Clash import URL",
|
mieruClashImportUrl: "Clash import URL",
|
||||||
copySubscriptionUrl: "Copy subscription URL",
|
copySubscriptionUrl: "Copy subscription URL",
|
||||||
|
showSubscriptionQr: "QR subscription",
|
||||||
copyYaml: "Copy YAML",
|
copyYaml: "Copy YAML",
|
||||||
|
mieruQrTitle: "Scan Mieru subscription",
|
||||||
mieruNote: "Mieru server uses mita. Paste subscription URL into iOS Clash/mihomo apps, not raw YAML. Client JSON is for mieru app.",
|
mieruNote: "Mieru server uses mita. Paste subscription URL into iOS Clash/mihomo apps, not raw YAML. Client JSON is for mieru app.",
|
||||||
copyConfig: "Copy config",
|
copyConfig: "Copy config",
|
||||||
savedKey: "saved key",
|
savedKey: "saved key",
|
||||||
@@ -444,7 +446,9 @@ const i18n = {
|
|||||||
mieruSubscriptionUrl: "URL подписки mihomo",
|
mieruSubscriptionUrl: "URL подписки mihomo",
|
||||||
mieruClashImportUrl: "URL импорта Clash",
|
mieruClashImportUrl: "URL импорта Clash",
|
||||||
copySubscriptionUrl: "Копировать URL подписки",
|
copySubscriptionUrl: "Копировать URL подписки",
|
||||||
|
showSubscriptionQr: "QR подписки",
|
||||||
copyYaml: "Копировать YAML",
|
copyYaml: "Копировать YAML",
|
||||||
|
mieruQrTitle: "Сканирование Mieru подписки",
|
||||||
mieruNote: "Mieru server работает через mita. В iOS Clash/mihomo вставляйте URL подписки, не сырой YAML. Client JSON для mieru app.",
|
mieruNote: "Mieru server работает через mita. В iOS Clash/mihomo вставляйте URL подписки, не сырой YAML. Client JSON для mieru app.",
|
||||||
copyConfig: "Копировать конфиг",
|
copyConfig: "Копировать конфиг",
|
||||||
savedKey: "ключ сохранён",
|
savedKey: "ключ сохранён",
|
||||||
@@ -2178,6 +2182,26 @@ function showUserQr(name) {
|
|||||||
$("#qrModal").hidden = false;
|
$("#qrModal").hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showMieruQr() {
|
||||||
|
const cfg = state.mieru || state.overview?.mieru || {};
|
||||||
|
const link = cfg.subscription_url || $("#mieruSubscriptionUrl")?.value || "";
|
||||||
|
if (!link) {
|
||||||
|
toast(t("qrUnavailable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.qrLink = link;
|
||||||
|
$("#qrTitle").textContent = t("mieruQrTitle");
|
||||||
|
$("#qrMeta").textContent = link;
|
||||||
|
const img = $("#qrImage");
|
||||||
|
img.alt = "Mieru subscription QR";
|
||||||
|
img.onerror = () => {
|
||||||
|
img.removeAttribute("src");
|
||||||
|
toast(t("qrUnavailable"));
|
||||||
|
};
|
||||||
|
img.src = `/api/mieru/qr?ts=${Date.now()}`;
|
||||||
|
$("#qrModal").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
const service = $("#logService").value;
|
const service = $("#logService").value;
|
||||||
const btn = $("#loadLogsBtn");
|
const btn = $("#loadLogsBtn");
|
||||||
@@ -2370,6 +2394,7 @@ $("#mieruStartBtn").addEventListener("click", () => controlMieru("start"));
|
|||||||
$("#mieruRestartBtn").addEventListener("click", () => controlMieru("restart"));
|
$("#mieruRestartBtn").addEventListener("click", () => controlMieru("restart"));
|
||||||
$("#mieruStopBtn").addEventListener("click", () => controlMieru("stop"));
|
$("#mieruStopBtn").addEventListener("click", () => controlMieru("stop"));
|
||||||
$("#mieruCopyUrlBtn").addEventListener("click", () => copyText($("#mieruSubscriptionUrl").value || ""));
|
$("#mieruCopyUrlBtn").addEventListener("click", () => copyText($("#mieruSubscriptionUrl").value || ""));
|
||||||
|
$("#mieruQrBtn").addEventListener("click", showMieruQr);
|
||||||
$("#mieruCopyYamlBtn").addEventListener("click", () => copyText($("#mieruMihomoYaml").value || ""));
|
$("#mieruCopyYamlBtn").addEventListener("click", () => copyText($("#mieruMihomoYaml").value || ""));
|
||||||
$("#siteMaskForm").addEventListener("submit", saveSiteMask);
|
$("#siteMaskForm").addEventListener("submit", saveSiteMask);
|
||||||
$("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask);
|
$("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask);
|
||||||
|
|||||||
@@ -511,6 +511,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<button id="mieruCopyUrlBtn" class="soft" type="button" data-i18n="copySubscriptionUrl">Copy subscription URL</button>
|
<button id="mieruCopyUrlBtn" class="soft" type="button" data-i18n="copySubscriptionUrl">Copy subscription URL</button>
|
||||||
|
<button id="mieruQrBtn" class="soft" type="button" data-i18n="showSubscriptionQr">QR</button>
|
||||||
<button id="mieruCopyYamlBtn" class="soft" type="button" data-i18n="copyYaml">Copy YAML</button>
|
<button id="mieruCopyYamlBtn" class="soft" type="button" data-i18n="copyYaml">Copy YAML</button>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
@@ -567,6 +568,6 @@
|
|||||||
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
|
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/app.js?v=2.5.0-admin30" type="module"></script>
|
<script src="/app.js?v=2.5.0-admin31" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
docs/images/mieru-qr.png
Normal file
BIN
docs/images/mieru-qr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/images/mieru-settings.png
Normal file
BIN
docs/images/mieru-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Reference in New Issue
Block a user