mirror of
https://github.com/andrey271192/PCAtelegram_web.git
synced 2026-09-20 11:55:32 +00:00
feat: manage mask site from admin
This commit is contained in:
23
README.md
23
README.md
@@ -68,6 +68,8 @@ ssh root@SERVER 'chmod +x /opt/pcatelegram_web/install.sh /opt/pcatelegram_web/i
|
||||
| `PCATELEGRAM_WEB_ADMIN_USER` | `admin` | Basic Auth login |
|
||||
| `PCATELEGRAM_WEB_ADMIN_PASSWORD` | `admin` | web-admin password |
|
||||
| `PCATELEGRAM_WEB_WARP_CONFIG` | `/opt/pcatelegram_web/warp.json` | WARP / WARP+ settings |
|
||||
| `PCATELEGRAM_WEB_SITE_ROOT` | `/var/www/pcatelegram_web-site` | root публичного HTML-сайта на 80 |
|
||||
| `PCATELEGRAM_WEB_NGINX_MASK_CONF` | `/etc/nginx/sites-available/pcatelegram_web-mask` | nginx config сайта на 80 |
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
@@ -126,6 +128,27 @@ Per-client runtime routing в текущем telemt не включается а
|
||||
|
||||
Один `telemt`-инстанс имеет один публичный порт и один сайт маскировки для всех клиентов. Разные порты или разные сайты маскировки на разных клиентов требуют отдельные `telemt` services/configs.
|
||||
|
||||
## Свой домен и сайт на 80
|
||||
|
||||
Чтобы домен открывал обычный публичный сайт:
|
||||
|
||||
1. В панели регистратора домена создай DNS `A` запись: `@` → IP сервера.
|
||||
2. Если нужен `www`, создай `A` запись `www` → IP сервера.
|
||||
3. Дождись DNS. Обычно от нескольких минут до нескольких часов.
|
||||
4. В web-admin открой `Settings` → `Domain and mask site`.
|
||||
5. Введи домен без `http://`, например `example.ru`.
|
||||
6. Нажми `Install site on 80`.
|
||||
|
||||
Если порт `80` свободен, web-admin поставит/настроит nginx и отдаст нейтральный одностраничный HTML-сайт без упоминаний proxy, VPN, MTProxy или PCAtelegram_web. Если порт занят чужим процессом, сохранение будет заблокировано.
|
||||
|
||||
Чтобы поставить свой HTML-сайт:
|
||||
|
||||
1. В том же блоке выбери `.html` / `.htm` файл.
|
||||
2. Нажми `Add HTML site`.
|
||||
3. Проверь `http://DOMAIN/`.
|
||||
|
||||
Лимит файла: 1 MB. Не клади в HTML секреты, токены, ключи и внутренние ссылки. Кнопка `Remove site` удаляет nginx config сайта с порта `80`, но не трогает `telemt` и ключи клиентов.
|
||||
|
||||
## Ошибки и решения
|
||||
|
||||
### Telegram пишет `соединение` и proxy не подключается
|
||||
|
||||
@@ -48,6 +48,9 @@ SHARED_443_CONFIG = Path(os.getenv("PCATELEGRAM_WEB_SHARED_443", "/opt/pcatelegr
|
||||
BACKUP_SCHEDULE_FILE = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_SCHEDULE", "/opt/pcatelegram_web/backup_schedule.json"))
|
||||
BACKUP_RESTORE_LOG = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_RESTORE_LOG", "/var/log/pcatelegram_web-restore.log"))
|
||||
WARP_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_WARP_CONFIG", "/opt/pcatelegram_web/warp.json"))
|
||||
WEBSITE_ROOT = Path(os.getenv("PCATELEGRAM_WEB_SITE_ROOT", "/var/www/pcatelegram_web-site"))
|
||||
NGINX_MASK_CONF = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_CONF", "/etc/nginx/sites-available/pcatelegram_web-mask"))
|
||||
NGINX_MASK_LINK = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_LINK", "/etc/nginx/sites-enabled/pcatelegram_web-mask"))
|
||||
|
||||
HOST = os.getenv("PCATELEGRAM_WEB_ADMIN_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PCATELEGRAM_WEB_ADMIN_PORT", "1984"))
|
||||
@@ -1432,6 +1435,197 @@ def site_status(config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def default_mask_html(domain: str = "") -> str:
|
||||
title = "Информационный центр"
|
||||
subtitle = "Полезные материалы, новости и практические заметки для повседневной работы."
|
||||
host = domain or "local"
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
:root {{ color-scheme: light; --text:#1f2937; --muted:#667085; --line:#d8e0ec; --bg:#f5f7fb; --card:#ffffff; --blue:#2563eb; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ margin:0; min-height:100vh; font-family: Arial, sans-serif; background:var(--bg); color:var(--text); }}
|
||||
header, main, footer {{ width:min(980px, calc(100% - 32px)); margin:auto; }}
|
||||
header {{ padding:42px 0 22px; display:flex; justify-content:space-between; gap:20px; align-items:center; border-bottom:1px solid var(--line); }}
|
||||
.brand {{ font-size:22px; font-weight:800; }}
|
||||
nav {{ color:var(--muted); font-size:14px; }}
|
||||
.hero {{ padding:64px 0 44px; }}
|
||||
h1 {{ margin:0 0 18px; font-size:clamp(34px, 6vw, 64px); line-height:1; letter-spacing:0; }}
|
||||
p {{ margin:0; color:var(--muted); font-size:20px; line-height:1.6; max-width:720px; }}
|
||||
.grid {{ display:grid; grid-template-columns:repeat(3, 1fr); gap:16px; margin:22px 0 70px; }}
|
||||
article {{ background:var(--card); border:1px solid var(--line); border-radius:12px; padding:22px; box-shadow:0 18px 45px rgba(15,23,42,.08); }}
|
||||
h2 {{ margin:0 0 10px; font-size:20px; }}
|
||||
article p {{ font-size:15px; line-height:1.5; }}
|
||||
footer {{ padding:24px 0 36px; color:var(--muted); font-size:14px; border-top:1px solid var(--line); }}
|
||||
@media (max-width:760px) {{ header {{ align-items:flex-start; flex-direction:column; }} .grid {{ grid-template-columns:1fr; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">{title}</div>
|
||||
<nav>{host}</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>Коротко. Понятно. По делу.</h1>
|
||||
<p>{subtitle}</p>
|
||||
</section>
|
||||
<section class="grid" aria-label="Разделы">
|
||||
<article><h2>Материалы</h2><p>Подборки и инструкции для людей, которым нужен быстрый ответ без лишнего шума.</p></article>
|
||||
<article><h2>Обновления</h2><p>Заметки о полезных изменениях, сервисах и рабочих сценариях.</p></article>
|
||||
<article><h2>Контакты</h2><p>Предложения и вопросы можно отправить владельцу сайта удобным способом.</p></article>
|
||||
</section>
|
||||
</main>
|
||||
<footer>© {time.strftime("%Y")} {title}</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def write_site_domain(domain: str) -> None:
|
||||
config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
config["domain"] = domain
|
||||
config["site_domain"] = domain
|
||||
config["site_updated_at"] = utc_now()
|
||||
save_json(PCATELEGRAM_WEB_CONFIG, config)
|
||||
|
||||
|
||||
def site_port80_conflicts() -> list[dict[str, Any]]:
|
||||
listeners, _ = collect_port_listeners(80)
|
||||
return [
|
||||
item for item in listeners
|
||||
if "nginx" not in str(item.get("process", "")).lower()
|
||||
]
|
||||
|
||||
|
||||
def site_mask_status() -> dict[str, Any]:
|
||||
config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
domain = str(config.get("site_domain") or config.get("domain") or "").strip()
|
||||
index = WEBSITE_ROOT / "index.html"
|
||||
listeners, errors = collect_port_listeners(80)
|
||||
installed = NGINX_MASK_LINK.exists() and index.exists()
|
||||
return {
|
||||
"domain": domain,
|
||||
"url": f"http://{domain}/" if domain else "",
|
||||
"installed": installed,
|
||||
"nginx": service_status("nginx"),
|
||||
"port": 80,
|
||||
"listeners": listeners,
|
||||
"conflicts": site_port80_conflicts(),
|
||||
"html_exists": index.exists(),
|
||||
"html_size": index.stat().st_size if index.exists() else 0,
|
||||
"html_mtime": int(index.stat().st_mtime) if index.exists() else 0,
|
||||
"ok": not errors,
|
||||
"error": "; ".join(errors[:2]),
|
||||
"checked_at": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
def write_mask_nginx_config(domain: str) -> None:
|
||||
server_name = domain if domain else "_"
|
||||
NGINX_MASK_CONF.parent.mkdir(parents=True, exist_ok=True)
|
||||
NGINX_MASK_LINK.parent.mkdir(parents=True, exist_ok=True)
|
||||
conf = f"""# PCAtelegram_web public mask site on port 80
|
||||
server {{
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name {server_name};
|
||||
|
||||
root {WEBSITE_ROOT};
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
location / {{
|
||||
try_files $uri $uri/ /index.html;
|
||||
}}
|
||||
|
||||
location ~ /\\. {{
|
||||
deny all;
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
tmp = NGINX_MASK_CONF.with_suffix(".tmp")
|
||||
tmp.write_text(conf, encoding="utf-8")
|
||||
os.chmod(tmp, 0o644)
|
||||
tmp.replace(NGINX_MASK_CONF)
|
||||
try:
|
||||
Path("/etc/nginx/sites-enabled/default").unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
if NGINX_MASK_LINK.exists() or NGINX_MASK_LINK.is_symlink():
|
||||
NGINX_MASK_LINK.unlink()
|
||||
NGINX_MASK_LINK.symlink_to(NGINX_MASK_CONF)
|
||||
|
||||
|
||||
def install_mask_site(domain: str, html: str = "") -> dict[str, Any]:
|
||||
if domain and not HOST_RE.match(domain):
|
||||
raise ValueError("domain must be a valid domain or empty")
|
||||
conflicts = site_port80_conflicts()
|
||||
if conflicts:
|
||||
names = ", ".join(f"{item.get('process')} {item.get('address')}" for item in conflicts[:3])
|
||||
raise RuntimeError(f"port 80 is busy: {names}")
|
||||
if html and len(html.encode("utf-8")) > 1024 * 1024:
|
||||
raise ValueError("html file is too large")
|
||||
if not html:
|
||||
html = default_mask_html(domain)
|
||||
WEBSITE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
index = WEBSITE_ROOT / "index.html"
|
||||
tmp = index.with_suffix(".tmp")
|
||||
tmp.write_text(html, encoding="utf-8")
|
||||
os.chmod(tmp, 0o644)
|
||||
tmp.replace(index)
|
||||
write_site_domain(domain)
|
||||
script = """
|
||||
set -euo pipefail
|
||||
source /opt/pcatelegram_web/lib/common.sh
|
||||
source /opt/pcatelegram_web/lib/i18n.sh
|
||||
source /opt/pcatelegram_web/lib/website.sh
|
||||
load_language "$(detect_language 2>/dev/null || echo en)" 2>/dev/null || true
|
||||
ensure_deps >/tmp/pcatelegram_web-site-ensure-deps.log 2>&1 || true
|
||||
install_nginx >/tmp/pcatelegram_web-site-nginx-install.log 2>&1
|
||||
"""
|
||||
code, _, stderr = run_bash_env(script, timeout=180)
|
||||
if code != 0:
|
||||
raise RuntimeError((stderr.strip().splitlines()[-1:] or ["nginx install failed"])[0])
|
||||
write_mask_nginx_config(domain)
|
||||
code, _, stderr = run(["nginx", "-t"], timeout=10)
|
||||
if code != 0:
|
||||
raise RuntimeError(stderr.strip() or "nginx config test failed")
|
||||
code, _, stderr = run(["systemctl", "restart", "nginx"], timeout=30)
|
||||
if code != 0:
|
||||
raise RuntimeError(stderr.strip() or "nginx restart failed")
|
||||
return site_mask_status()
|
||||
|
||||
|
||||
def remove_mask_site() -> dict[str, Any]:
|
||||
if NGINX_MASK_LINK.exists() or NGINX_MASK_LINK.is_symlink():
|
||||
NGINX_MASK_LINK.unlink()
|
||||
if NGINX_MASK_CONF.exists():
|
||||
NGINX_MASK_CONF.unlink()
|
||||
code, _, _ = run(["nginx", "-t"], timeout=10)
|
||||
if code == 0:
|
||||
run(["systemctl", "reload", "nginx"], timeout=15)
|
||||
config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {}
|
||||
if isinstance(config, dict):
|
||||
config["site_enabled"] = False
|
||||
config["site_updated_at"] = utc_now()
|
||||
save_json(PCATELEGRAM_WEB_CONFIG, config)
|
||||
return site_mask_status()
|
||||
|
||||
|
||||
def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]:
|
||||
if not HISTORY_FILE.exists():
|
||||
return []
|
||||
@@ -1957,6 +2151,7 @@ def overview_payload() -> dict[str, Any]:
|
||||
"admin_bind": {"host": HOST, "port": PORT},
|
||||
"config": public_config(config),
|
||||
"site_status": site_status(config),
|
||||
"site_mask": site_mask_status(),
|
||||
"users_count": len(users),
|
||||
"services": services,
|
||||
"port_map": port_status(int(config.get("port") or read_telemt_port() or 443)),
|
||||
@@ -2194,6 +2389,8 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
})
|
||||
elif path == "/api/site/check":
|
||||
self.send_json({"ok": True, "data": site_status()})
|
||||
elif path == "/api/site/mask":
|
||||
self.send_json({"ok": True, "data": site_mask_status()})
|
||||
elif path == "/api/logs":
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
service = qs.get("service", ["telemt"])[0]
|
||||
@@ -2252,6 +2449,28 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_error_json(500, f"failed to save routing: {exc}")
|
||||
return
|
||||
self.send_json({"ok": True, "data": payload})
|
||||
elif path == "/api/site/mask":
|
||||
action = str(body.get("action") or "install").strip().lower()
|
||||
domain = str(body.get("domain") or "").strip().lower()
|
||||
html = str(body.get("html") or "")
|
||||
try:
|
||||
if action in {"install", "upload"}:
|
||||
payload = install_mask_site(domain, html)
|
||||
elif action == "remove":
|
||||
payload = remove_mask_site()
|
||||
else:
|
||||
self.send_error_json(400, "unsupported site action")
|
||||
return
|
||||
except ValueError as exc:
|
||||
self.send_error_json(400, str(exc))
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
self.send_error_json(409, str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, f"failed to manage site: {exc}")
|
||||
return
|
||||
self.send_json({"ok": True, "data": payload})
|
||||
elif path.startswith("/api/users/") and path.endswith("/max-ips"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/max-ips")])
|
||||
try:
|
||||
|
||||
@@ -133,6 +133,21 @@ const i18n = {
|
||||
warpPerUserNote: "One-client WARP profile is stored here. If warp-cli is missing, it will be installed on save. Runtime per-client routing needs a dedicated telemt route; global WARP applies to all clients.",
|
||||
warpAllNote: "Global WARP applies to all proxy traffic. If warp-cli is missing, it will be installed on save, then connected.",
|
||||
savedKey: "saved key",
|
||||
siteMaskEyebrow: "Public site",
|
||||
siteMaskTitle: "Domain and mask site",
|
||||
siteMaskDomain: "Your domain",
|
||||
siteMaskInstall: "Install site on 80",
|
||||
siteMaskRemove: "Remove site",
|
||||
siteMaskUpload: "Upload your HTML",
|
||||
siteMaskUploadBtn: "Add HTML site",
|
||||
siteMaskInstalled: "site installed",
|
||||
siteMaskNotInstalled: "site not installed",
|
||||
siteMaskBusy: "port 80 busy",
|
||||
siteMaskReady: "port 80 ready",
|
||||
siteMaskNote: "A neutral one-page site is served on port 80. It does not mention proxy or VPN. Point the A record to this server first.",
|
||||
siteMaskUploaded: "HTML site uploaded",
|
||||
siteMaskRemoved: "Site removed",
|
||||
siteMaskSaved: "Site installed",
|
||||
dashboard: "Dashboard",
|
||||
noKeys: "No keys yet",
|
||||
noBackups: "No backups yet",
|
||||
@@ -388,6 +403,21 @@ const i18n = {
|
||||
warpPerUserNote: "Профиль WARP для одного клиента сохраняется здесь. Если warp-cli нет, он установится при сохранении. Runtime-маршрут на одного клиента требует отдельный маршрут telemt; global WARP действует на всех клиентов.",
|
||||
warpAllNote: "Global WARP применится ко всему proxy-трафику. Если warp-cli нет, он установится при сохранении и затем подключится.",
|
||||
savedKey: "ключ сохранён",
|
||||
siteMaskEyebrow: "Публичный сайт",
|
||||
siteMaskTitle: "Домен и сайт маскировки",
|
||||
siteMaskDomain: "Свой домен",
|
||||
siteMaskInstall: "Установить сайт на 80",
|
||||
siteMaskRemove: "Удалить сайт",
|
||||
siteMaskUpload: "Загрузить свой HTML",
|
||||
siteMaskUploadBtn: "Добавить HTML сайт",
|
||||
siteMaskInstalled: "сайт установлен",
|
||||
siteMaskNotInstalled: "сайт не установлен",
|
||||
siteMaskBusy: "порт 80 занят",
|
||||
siteMaskReady: "порт 80 доступен",
|
||||
siteMaskNote: "На порту 80 отдается нейтральный одностраничный сайт. Без упоминаний proxy/VPN. Сначала направьте A-запись домена на этот сервер.",
|
||||
siteMaskUploaded: "HTML сайт загружен",
|
||||
siteMaskRemoved: "Сайт удалён",
|
||||
siteMaskSaved: "Сайт установлен",
|
||||
dashboard: "Обзор",
|
||||
noKeys: "Ключей пока нет",
|
||||
noBackups: "Бекапов пока нет",
|
||||
@@ -535,6 +565,7 @@ const state = {
|
||||
routingDirty: false,
|
||||
routingPreviewMap: null,
|
||||
warp: null,
|
||||
siteMask: null,
|
||||
qrLink: "",
|
||||
pendingUsers: new Set(),
|
||||
refreshingAll: false,
|
||||
@@ -689,6 +720,7 @@ function applyI18n() {
|
||||
renderBackupSchedule();
|
||||
renderRoutingSettings();
|
||||
renderWarpSettings();
|
||||
renderSiteMaskSettings();
|
||||
updatePageTitle();
|
||||
updateAutoRefreshToggle();
|
||||
}
|
||||
@@ -1390,6 +1422,39 @@ function renderWarpSettings() {
|
||||
: `${t("warpAllNote")}${statusLine ? ` ${statusLine}` : ""}`;
|
||||
}
|
||||
|
||||
function siteMaskSummary(payload = {}) {
|
||||
const conflicts = payload.conflicts || [];
|
||||
if (conflicts.length) {
|
||||
return `${t("siteMaskBusy")}: ${conflicts.map((item) => `${item.process} ${item.address}`).join(", ")}`;
|
||||
}
|
||||
const listeners = payload.listeners || [];
|
||||
const listenerText = listeners.length
|
||||
? listeners.map((item) => `${item.process} ${item.address}`).join(", ")
|
||||
: t("siteMaskReady");
|
||||
const domain = payload.domain || "--";
|
||||
const html = payload.html_exists ? fmtBytes(payload.html_size || 0) : "--";
|
||||
return `${listenerText}. domain: ${domain}. html: ${html}. ${t("siteMaskNote")}`;
|
||||
}
|
||||
|
||||
function renderSiteMaskSettings(payload = null) {
|
||||
const cfg = payload || state.siteMask || state.overview?.site_mask || {};
|
||||
const domainEl = $("#siteMaskDomain");
|
||||
const statusEl = $("#siteMaskStatus");
|
||||
const noteEl = $("#siteMaskNote");
|
||||
if (!domainEl || !statusEl || !noteEl) return;
|
||||
if (document.activeElement !== domainEl) {
|
||||
domainEl.value = cfg.domain || "";
|
||||
}
|
||||
const conflicts = cfg.conflicts || [];
|
||||
const installed = Boolean(cfg.installed);
|
||||
statusEl.textContent = conflicts.length
|
||||
? t("siteMaskBusy")
|
||||
: (installed ? t("siteMaskInstalled") : t("siteMaskNotInstalled"));
|
||||
statusEl.className = `status-pill ${conflicts.length ? "health-error" : (installed ? "health-ok" : "")}`;
|
||||
noteEl.textContent = siteMaskSummary(cfg);
|
||||
noteEl.classList.toggle("error", Boolean(conflicts.length));
|
||||
}
|
||||
|
||||
function renderBackups(backups) {
|
||||
const box = $("#backupsList");
|
||||
renderBackupSchedule();
|
||||
@@ -1547,6 +1612,7 @@ async function refreshAll(options = {}) {
|
||||
state.backupSchedule = state.overview.backup_schedule || state.backupSchedule;
|
||||
state.routing = state.overview.routing || state.routing;
|
||||
state.warp = state.overview.warp || state.warp;
|
||||
state.siteMask = state.overview.site_mask || state.siteMask;
|
||||
updateLanguageFromOverview(state.overview);
|
||||
state.users = await api("/api/users");
|
||||
ensureUserTrafficSelection();
|
||||
@@ -1572,6 +1638,7 @@ async function refreshAll(options = {}) {
|
||||
renderUsers();
|
||||
renderRoutingSettings();
|
||||
renderWarpSettings();
|
||||
renderSiteMaskSettings();
|
||||
if (state.page === "traffic") {
|
||||
await refreshStats();
|
||||
} else if (state.page === "keys") {
|
||||
@@ -1817,6 +1884,84 @@ async function saveWarpSettings(eventObj) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSiteMask(eventObj) {
|
||||
eventObj.preventDefault();
|
||||
const form = eventObj.currentTarget;
|
||||
const controls = Array.from(form.querySelectorAll("input, button"));
|
||||
controls.forEach((control) => { control.disabled = true; });
|
||||
try {
|
||||
const data = await api("/api/site/mask", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "install",
|
||||
domain: $("#siteMaskDomain").value.trim().toLowerCase(),
|
||||
}),
|
||||
});
|
||||
state.siteMask = data;
|
||||
renderSiteMaskSettings();
|
||||
addEvent(t("siteMaskSaved"), data.url || data.domain || "80");
|
||||
toast(t("siteMaskSaved"));
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
controls.forEach((control) => { control.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSiteMask() {
|
||||
const btn = $("#siteMaskRemoveBtn");
|
||||
if (!window.confirm(t("siteMaskRemove"))) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const data = await api("/api/site/mask", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "remove" }),
|
||||
});
|
||||
state.siteMask = data;
|
||||
renderSiteMaskSettings();
|
||||
addEvent(t("siteMaskRemoved"), "80");
|
||||
toast(t("siteMaskRemoved"));
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSiteMaskHtml() {
|
||||
const fileEl = $("#siteMaskHtmlFile");
|
||||
const file = fileEl?.files?.[0];
|
||||
if (!file) {
|
||||
toast(t("siteMaskUpload"));
|
||||
return;
|
||||
}
|
||||
const btn = $("#siteMaskUploadBtn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const html = await file.text();
|
||||
const data = await api("/api/site/mask", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "upload",
|
||||
domain: $("#siteMaskDomain").value.trim().toLowerCase(),
|
||||
html,
|
||||
}),
|
||||
});
|
||||
state.siteMask = data;
|
||||
fileEl.value = "";
|
||||
renderSiteMaskSettings();
|
||||
addEvent(t("siteMaskUploaded"), file.name);
|
||||
toast(t("siteMaskUploaded"));
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
const btn = $("#createBackupBtn");
|
||||
btn.disabled = true;
|
||||
@@ -2066,6 +2211,9 @@ $("#routingPort").addEventListener("change", (eventObj) => checkRoutingPort(even
|
||||
$("#warpSettingsForm").addEventListener("submit", saveWarpSettings);
|
||||
$("#warpScope").addEventListener("change", renderWarpSettings);
|
||||
$("#warpUserSelect").addEventListener("change", renderWarpSettings);
|
||||
$("#siteMaskForm").addEventListener("submit", saveSiteMask);
|
||||
$("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask);
|
||||
$("#siteMaskUploadBtn").addEventListener("click", uploadSiteMaskHtml);
|
||||
document.addEventListener("focusout", () => {
|
||||
setTimeout(runDeferredRefresh, 120);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}());
|
||||
</script>
|
||||
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin28">
|
||||
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin29">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -129,6 +129,32 @@
|
||||
<div class="service-grid" id="services"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n="siteMaskEyebrow">Public site</p>
|
||||
<h2 data-i18n="siteMaskTitle">Domain and mask site</h2>
|
||||
</div>
|
||||
<span id="siteMaskStatus" class="status-pill">--</span>
|
||||
</div>
|
||||
<form id="siteMaskForm" class="settings-form">
|
||||
<label>
|
||||
<span data-i18n="siteMaskDomain">Your domain</span>
|
||||
<input id="siteMaskDomain" name="domain" autocomplete="off" spellcheck="false" placeholder="example.ru">
|
||||
</label>
|
||||
<p id="siteMaskNote" class="modal-note"></p>
|
||||
<div class="inline-form">
|
||||
<button type="submit" data-i18n="siteMaskInstall">Install site on 80</button>
|
||||
<button id="siteMaskRemoveBtn" class="danger" type="button" data-i18n="siteMaskRemove">Remove site</button>
|
||||
</div>
|
||||
<label>
|
||||
<span data-i18n="siteMaskUpload">Upload your HTML</span>
|
||||
<input id="siteMaskHtmlFile" type="file" accept=".html,.htm,text/html">
|
||||
</label>
|
||||
<button id="siteMaskUploadBtn" class="soft" type="button" data-i18n="siteMaskUploadBtn">Add HTML site</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
@@ -482,6 +508,6 @@
|
||||
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js?v=2.5.0-admin28" type="module"></script>
|
||||
<script src="/app.js?v=2.5.0-admin29" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user