feat: custom brand name everywhere + a working client-facing site out of the box
User ask, paraphrased: install it, get help wiring up payments, and
immediately have a ready site under your own name — not "MBS Panel"
plastered everywhere and a bunch of manual follow-up.
Two things were actually broken/missing, found by tracing every surface
a real customer or the operator would see:
1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription
page, admin panel splash/title/sidebar, legal pages, 2FA issuer,
install.sh) with zero way to change it short of editing source.
New BRAND_NAME config value (config.py default "MBS Panel", so this
is 100% backward compatible for existing installs) wired through
everywhere via the same live-settings pattern from the last commit
(settings.get_brand_name(), no restart needed anywhere it's used).
New Настройки → «Название» section in the admin panel to change it.
2. site/index.html and site/cabinet.html — a fully-built landing page +
personal-cabinet template, already in the repo — were never actually
served by anything. Not mounted by FastAPI, not deployed by
install.sh, not linked from anywhere. Pure dead weight: a repo that
looked like it shipped a client site but didn't. Now legal.py gets a
render_site_page() (same {{TOKEN}} substitution + HTML-escaping as
the existing offer/privacy renderer, new tokens: BRAND_NAME,
SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded
landing page on any host that isn't PANEL_DOMAIN (in practice:
SUB_DOMAIN, which nginx already routes to this backend — zero
install.sh/nginx/certbot changes needed, so this is live on every
existing install without an upgrade step beyond `mbs update`).
GET /cabinet.html serves the cabinet. Landing page's pricing section
now fetches real, live prices from a new public GET /api/plans
instead of showing static duration labels with no numbers.
Also fixed along the way, same staleness-bug class as the payments/HWID
fix last commit, found by grepping for every remaining frozen `from
config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen
constants in api.py (mbs-api never restarts itself). Concretely this
meant: changing the bot via Настройки → Telegram-бот would leave
_tg_send_message (payment-received notifications) silently trying the
OLD token, admin_get_bot_settings showing the OLD username right after
a successful save, and gift-code links pointing at the OLD bot — all
until a manual mbs restart, same shape as the Platega-secret bug fixed
last commit. Added settings.bot_credentials(), wired it through every
call site (hoisted out of loops where relevant, same N+1 discipline as
always), removed the now-stale "выполни mbs restart" copy from the bot
settings hint.
legal.py's own BOT_USERNAME import was frozen too (used by the /offer
and /privacy {{BOT_USERNAME}} token) — switched to reading it live
in-module (no settings.py import from legal.py, would've been circular
since settings.py already imports legal.py for the env reader).
install.sh: new interactive prompt for the brand name (default "MBS
Panel", so hitting enter reproduces today's behavior exactly), written
to .env, echoed in the final summary along with the now-live site URL.
Verification: same story as always — api.py/bot.py still can't import
locally (no pydantic-core wheel for Python 3.14 on this machine).
py_compile + pyflakes clean across the whole repo. Real runtime test
against an isolated .env fixture: brand name and bot-credential live
reads (no reimport), render_site_page() token substitution correctness
on the actual site/index.html and site/cabinet.html files including an
XSS check (brand name containing <script> comes out HTML-escaped), and
a regression check that adding the BRAND_NAME token to the existing
legal.render() didn't break offer.html/privacy.html. Extracted
SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't
import the module, but can pull the string constants) and ran the real
.format() calls against them to catch any brace-escaping mistake in the
new {brand_name} placeholder — CSS braces in those templates are
already double-escaped for .format(), easy to get wrong. Extracted and
node --check'd admin.html's whole inline script, div-tag-balance check
on the full file. install.sh's new prompt+heredoc snippet run standalone
with piped stdin (both a brand name with spaces and an empty/default
input), round-tripped the resulting .env back through the real
env-parsing logic. Extended the existing CI "app wiring" step (which
does import api/bot for real on Linux) with branding assertions calling
the actual route functions directly (api.root(), api.public_plans(),
api.public_branding()) — ran every part of that step's new logic that
doesn't need api.py locally first, to catch what's catchable before
trusting the rest to CI once the account's abuse-review lifts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7d140711fd
commit
7cc50973f6
14 changed files with 228 additions and 52 deletions
|
|
@ -1,5 +1,9 @@
|
|||
# Copy this to .env and fill in real values. Never commit .env.
|
||||
|
||||
# Shown to clients everywhere: site, bot, subscription page, offer/privacy, panel
|
||||
# login. Also editable live from Настройки in the admin panel, no restart needed.
|
||||
BRAND_NAME=MBS Panel
|
||||
|
||||
# From @BotFather
|
||||
BOT_TOKEN=123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
BOT_USERNAME=YourBot_robot
|
||||
|
|
|
|||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
|
|
@ -109,6 +109,36 @@ jobs:
|
|||
assert db.get_device(1, "hwid-aaaaaaaaaa") is not None
|
||||
|
||||
print("app wiring + payments + HWID logic OK")
|
||||
|
||||
import legal
|
||||
import settings
|
||||
|
||||
assert settings.get_brand_name() == "MBS Panel"
|
||||
legal.update_env_var("BRAND_NAME", "CI Test Brand")
|
||||
assert settings.get_brand_name() == "CI Test Brand"
|
||||
|
||||
index_html = legal.render_site_page("index.html")
|
||||
assert "CI Test Brand" in index_html
|
||||
assert "MBS Panel" not in index_html
|
||||
assert "example.com" not in index_html
|
||||
assert "YourBot_robot" not in index_html
|
||||
assert "{{" not in index_html and "}}" not in index_html
|
||||
|
||||
cabinet_html = legal.render_site_page("cabinet.html")
|
||||
assert "CI Test Brand" in cabinet_html
|
||||
assert "{{" not in cabinet_html and "}}" not in cabinet_html
|
||||
|
||||
fake_request = type("FakeRequest", (), {"headers": {}})()
|
||||
root_resp = api.root(fake_request)
|
||||
assert "CI Test Brand" in root_resp
|
||||
|
||||
plans_resp = api.public_plans()
|
||||
assert plans_resp["plans"][0]["code"] == "7d"
|
||||
|
||||
branding_resp = api.public_branding()
|
||||
assert branding_resp["brand_name"] == "CI Test Brand"
|
||||
|
||||
print("branding: site templates + public routes render live, no restart OK")
|
||||
PYEOF
|
||||
|
||||
- name: Smoke test TOTP, backup/restore, node reorder, multi-admin, rate-limit
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
## Что внутри
|
||||
|
||||
- **Бот** (aiogram 3) — выдача подписок по кнопкам, гифт-коды, привязка тарифов (7 дней / месяц / 3 месяца / полгода / год), автоматическое отключение по истечении подписки (не раз в полчаса, а раз в 90 секунд — важно, чтобы просрочка реально обрывала доступ, а не продолжала работать).
|
||||
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, личный кабинет, JSON API для сайта.
|
||||
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, готовый клиентский лендинг + личный кабинет (живут прямо в панели, подставляют название и реальные тарифы сами, ничего отдельно хостить не надо).
|
||||
- **Своё название бренда** — панель, бот, сайт, страница подписки, оферта/политика показывают одно и то же настраиваемое название вместо дефолтного «MBS Panel», меняется в один клик из Настроек, применяется сразу.
|
||||
- **Админ-панель** (чистый HTML/CSS/JS, без фреймворков и сборки) — дашборд, полная карточка юзера (история подписок, ручная выдача, устройства), подписки, гифт-коды, ноды (полное редактирование, не только вкл/выкл), трафик по Stats API самого Xray со сбросом счётчика по клику.
|
||||
- **Мультинодовость** — добавляешь новую ноду в панели, получаешь одну команду `bash <(curl ...)`, вставляешь на чистый сервер — нода сама ставит Xray, генерит ключи, регистрируется в панели. Как у Remnawave/3x-ui, только свой велосипед.
|
||||
- **Протоколы на выбор при добавлении ноды**: VLESS TCP+Reality, VLESS gRPC+Reality, VLESS XHTTP+Reality, VLESS WS+TLS (с реальным Let's Encrypt сертификатом), Hysteria2 (QUIC, отдельный процесс, obfs).
|
||||
|
|
@ -129,6 +130,7 @@ bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
|
|||
- Зайди на `https://panel.example.com`, залогинься паролем из вывода скрипта.
|
||||
- Смени пароль в любой момент: `mbs pass новый_пароль` (без аргумента — сгенерит случайный).
|
||||
- В боте у себя (Telegram ID из ADMIN_IDS) появится админ-меню.
|
||||
- На `https://sub.example.com` уже живёт готовый клиентский сайт (лендинг + личный кабинет) с подставленным названием и реальными тарифами — ничего отдельно разворачивать не нужно. Название меняется в Настройки → «Название» в панели, применяется сразу везде (сайт, бот, страница подписки, оферта/политика).
|
||||
|
||||
В конце установки `install.sh` шлёт один пинг на `stats.api.savsis.xyz` (только название ОС) — просто счётчик "сколько раз панель установили", никаких доменов/токенов/паролей туда не уходит, IP не сохраняется. Отключить: `MBS_SKIP_STATS=1 sudo bash install.sh`.
|
||||
|
||||
|
|
|
|||
54
admin.html
54
admin.html
|
|
@ -278,7 +278,7 @@
|
|||
<div class="login-card">
|
||||
<div class="splash">
|
||||
<div class="splash-mark"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="16" x2="6" y2="8"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="18" y1="14" x2="18" y2="10"/></svg></div>
|
||||
<div class="splash-title">MBS Panel</div>
|
||||
<div class="splash-title" id="splash-brand-name">MBS Panel</div>
|
||||
<div class="splash-tagline">made by savsis</div>
|
||||
</div>
|
||||
<div id="login-step-password">
|
||||
|
|
@ -300,7 +300,7 @@
|
|||
|
||||
<div id="app">
|
||||
<div class="sidebar">
|
||||
<div class="brand"><div class="mark"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="16" x2="6" y2="8"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="18" y1="14" x2="18" y2="10"/></svg></div>MBS Panel</div>
|
||||
<div class="brand"><div class="mark"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="16" x2="6" y2="8"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="18" y1="14" x2="18" y2="10"/></svg></div><span id="sidebar-brand-name">MBS Panel</span></div>
|
||||
<div class="nav-item active" data-view="dashboard" onclick="showView('dashboard')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Дашборд</div>
|
||||
<div class="nav-item" data-view="subscriptions" onclick="showView('subscriptions')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/><line x1="7" y1="17" x2="13" y2="17"/></svg>Подписки</div>
|
||||
<div class="nav-item" data-view="gifts" onclick="showView('gifts')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="13" rx="1.5"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="8" x2="12" y2="21"/><path d="M12 8c-1.2 0-2.3-1.3-2.3-2.6C9.7 4 10.6 3 11.6 3c1.4 0 2.4 2 .4 5"/><path d="M12 8c1.2 0 2.3-1.3 2.3-2.6C14.3 4 13.4 3 12.4 3c-1.4 0-2.4 2-.4 5"/></svg>Гифт-коды</div>
|
||||
|
|
@ -582,6 +582,12 @@
|
|||
<p>На 443 порту одновременно живёт и настоящий HTTPS (для сайта/подписки), и замаскированный под HTTPS VLESS+Reality — их разводит <code>nginx stream</code> модуль по SNI входящего TLS-соединения, до расшифровки.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-block">
|
||||
<h2>Название и клиентский сайт</h2>
|
||||
<p>Настройки → «Название» — своё название бренда вместо дефолтного «MBS Panel», показывается сразу везде: заголовок и сайдбар панели, сообщения бота, страница подписки, оферта/политика, otpauth-issuer в приложении-аутентификаторе при включении 2FA. Применяется мгновенно, без рестарта.</p>
|
||||
<p>На домене подписок (<code>SUB_DOMAIN</code>) панель теперь сама отдаёт готовый клиентский сайт — корень (<code>/</code>) рендерит <code>site/index.html</code> (лендинг с живыми тарифами из <code>/api/plans</code>), <code>/cabinet.html</code> — личный кабинет по токену из бота. Оба шаблона лежат в репо (<code>site/</code>) — правишь HTML/CSS напрямую, если нужен свой дизайн, панель только подставляет название/домены/юзернейм бота при каждом запросе.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-block">
|
||||
<h2>Ноды</h2>
|
||||
<p><b>Локальная нода</b> (обычно <code>de1</code>) — Xray на том же сервере, что и панель, управляется напрямую правкой <code>config.json</code>. <b>Управляемые ноды</b> — отдельные серверы, панель ходит на них по SSH management-ключу (генерится сам при первом добавлении ноды, публичная часть раздаётся install-скриптом ноды — панель никогда не просит пароль от нового сервера).</p>
|
||||
|
|
@ -626,13 +632,24 @@
|
|||
<div class="page-title">Настройки</div>
|
||||
<div class="page-sub">Смена телеграм-бота без переустановки панели</div>
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Название</h2></div>
|
||||
<p class="page-sub" style="margin-bottom:16px">Показывается везде, где сейчас видят клиенты и ты сам: сайт, бот, страница подписки, оферта/политика, вход в панель.</p>
|
||||
<div class="form-row">
|
||||
<div><label class="f">Название бренда</label><input type="text" id="brand-name-input" placeholder="MBS Panel"></div>
|
||||
<div style="flex:0"><label class="f"> </label><button class="btn" onclick="saveBrandName()">Сохранить</button></div>
|
||||
</div>
|
||||
<p class="check-hint">Применяется сразу везде, без рестарта.</p>
|
||||
<div id="brand-name-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top:20px">
|
||||
<div class="section-head"><h2>Telegram-бот</h2></div>
|
||||
<p class="page-sub" style="margin-bottom:16px">Сейчас: <b id="settings-bot-username">—</b> (токен: <span id="settings-bot-token" style="font-family:'Fira Mono',monospace">—</span>)</p>
|
||||
<div class="form-row">
|
||||
<div><label class="f">Новый токен (от @BotFather)</label><input type="text" id="settings-bot-token-input" placeholder="123456789:AAAA..."></div>
|
||||
<div style="flex:0"><label class="f"> </label><button class="btn" onclick="saveBotSettings()">Сменить бота</button></div>
|
||||
</div>
|
||||
<p class="check-hint">Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. После смены перезапустится только бот; если уведомления от api (например об оплате) продолжат идти со старого бота, выполни на сервере <code>mbs restart</code>.</p>
|
||||
<p class="check-hint">Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. Применяется сразу — бот перезапускается сам, а панель (уведомления об оплате, ссылки на бота) подхватывает новый токен и юзернейм без рестарта.</p>
|
||||
<div id="settings-bot-result"></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -797,7 +814,7 @@ function showView(name) {
|
|||
if (name === "nodes") loadNodes();
|
||||
if (name === "traffic") loadTraffic();
|
||||
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
|
||||
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); loadHwidSettings(); }
|
||||
if (name === "settings") { loadBrandName(); loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); loadHwidSettings(); }
|
||||
}
|
||||
|
||||
const COUNTRIES = [
|
||||
|
|
@ -1190,6 +1207,25 @@ async function saveHwidSettings() {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadBrandName() {
|
||||
const res = await fetch("/api/branding").then((r) => r.json());
|
||||
document.getElementById("brand-name-input").value = res.brand_name || "";
|
||||
document.getElementById("brand-name-result").innerHTML = "";
|
||||
}
|
||||
|
||||
async function saveBrandName() {
|
||||
const brand_name = document.getElementById("brand-name-input").value.trim();
|
||||
const result = document.getElementById("brand-name-result");
|
||||
if (!brand_name) return;
|
||||
try {
|
||||
const res = await api("/admin/api/branding", { method: "POST", body: JSON.stringify({ brand_name }) });
|
||||
applyBrandName(res.brand_name);
|
||||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — применилось сразу</p>';
|
||||
} catch (e) {
|
||||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBotSettings() {
|
||||
const data = await api("/admin/api/settings/bot");
|
||||
document.getElementById("settings-bot-username").textContent = "@" + data.username;
|
||||
|
|
@ -1669,8 +1705,18 @@ async function createManualNode() {
|
|||
loadNodes();
|
||||
}
|
||||
|
||||
function applyBrandName(name) {
|
||||
if (!name) return;
|
||||
document.title = name;
|
||||
const splash = document.getElementById("splash-brand-name");
|
||||
if (splash) splash.textContent = name;
|
||||
const sidebar = document.getElementById("sidebar-brand-name");
|
||||
if (sidebar) sidebar.textContent = name;
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
initDropdowns();
|
||||
fetch("/api/branding").then((r) => r.json()).then((d) => applyBrandName(d.brand_name)).catch(() => {});
|
||||
try {
|
||||
const me = await api("/admin/api/me");
|
||||
if (me.authenticated) showApp(); else showLogin();
|
||||
|
|
|
|||
68
api.py
68
api.py
|
|
@ -21,7 +21,7 @@ import settings
|
|||
import totp
|
||||
import webhooks
|
||||
import xray_manager
|
||||
from config import SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, BOT_USERNAME, BOT_TOKEN, BASE_DIR
|
||||
from config import SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, BASE_DIR
|
||||
|
||||
HWID_RE = re.compile(r"^[a-zA-Z0-9=-]{10,64}$")
|
||||
ENV_PATH = os.path.join(BASE_DIR, ".env")
|
||||
|
|
@ -74,7 +74,7 @@ SUB_PAGE_TEMPLATE = """<!doctype html>
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — подписка</title>
|
||||
<title>{brand_name} — подписка</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -135,14 +135,14 @@ SUB_PAGE_TEMPLATE = """<!doctype html>
|
|||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="badge">MBS Panel</div>
|
||||
<div class="badge">{brand_name}</div>
|
||||
<h1>Подписка готова</h1>
|
||||
<p class="sub">Нажми кнопку — сервер добавится в Happ автоматически, или отсканируй QR другим устройством</p>
|
||||
<a class="btn" href="happ://add/{sub_url}">Добавить в Happ</a>
|
||||
<a class="btn secondary" href="{sub_url}">Открыть ссылку подписки</a>
|
||||
<div class="qr-box" id="qr"></div>
|
||||
<div class="link-box">{sub_url}</div>
|
||||
<div class="thanks">Спасибо, что пользуетесь MBS Panel.<br>Нет Happ? Скачай: <a href="https://apps.apple.com/us/app/happ-proxy-utility/id6504287215" target="_blank">App Store</a> · <a href="https://play.google.com/store/apps/details?id=com.happproxy" target="_blank">Google Play</a></div>
|
||||
<div class="thanks">Спасибо, что пользуетесь {brand_name}.<br>Нет Happ? Скачай: <a href="https://apps.apple.com/us/app/happ-proxy-utility/id6504287215" target="_blank">App Store</a> · <a href="https://play.google.com/store/apps/details?id=com.happproxy" target="_blank">Google Play</a></div>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
|
|
@ -160,7 +160,7 @@ SUB_PAGE_EXPIRED_TEMPLATE = """<!doctype html>
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — подписка</title>
|
||||
<title>{brand_name} — подписка</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -200,7 +200,7 @@ SUB_PAGE_EXPIRED_TEMPLATE = """<!doctype html>
|
|||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="badge">MBS Panel</div>
|
||||
<div class="badge">{brand_name}</div>
|
||||
<h1>Подписка истекла</h1>
|
||||
<p class="sub">Доступ по этой ссылке закончился. Продли подписку в боте — ссылка останется той же, ничего заново настраивать не нужно.</p>
|
||||
<a class="btn" href="https://t.me/{bot_username}" target="_blank">Продлить в боте</a>
|
||||
|
|
@ -222,10 +222,12 @@ def get_subscription(token: str, request: Request):
|
|||
subs = db.list_active_subscriptions(tg_id=user["tg_id"])
|
||||
ua = request.headers.get("user-agent", "")
|
||||
if not _is_app_client(ua):
|
||||
brand_name = settings.get_brand_name()
|
||||
if not subs:
|
||||
return HTMLResponse(SUB_PAGE_EXPIRED_TEMPLATE.format(bot_username=BOT_USERNAME))
|
||||
_, bot_username = settings.bot_credentials()
|
||||
return HTMLResponse(SUB_PAGE_EXPIRED_TEMPLATE.format(bot_username=bot_username, brand_name=brand_name))
|
||||
sub_url = f"https://{SUB_DOMAIN}/sub/{token}"
|
||||
return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url))
|
||||
return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url, brand_name=brand_name))
|
||||
|
||||
hwid_cfg = settings.get_hwid_settings()
|
||||
if hwid_cfg["enabled"]:
|
||||
|
|
@ -286,7 +288,8 @@ def install_script(token: str):
|
|||
|
||||
|
||||
def _tg_send_message(tg_id: int, text: str):
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||
bot_token, _ = settings.bot_credentials()
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
data = json.dumps({"chat_id": tg_id, "text": text, "parse_mode": "HTML"}).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
|
|
@ -706,7 +709,7 @@ def admin_2fa_status(request: Request):
|
|||
def admin_2fa_setup(request: Request):
|
||||
current = _require_current_admin(request)
|
||||
secret = totp.generate_secret()
|
||||
return {"secret": secret, "uri": totp.uri(secret, current["username"])}
|
||||
return {"secret": secret, "uri": totp.uri(secret, current["username"], issuer=settings.get_brand_name())}
|
||||
|
||||
|
||||
@app.post("/admin/api/2fa/enable")
|
||||
|
|
@ -734,8 +737,9 @@ def admin_2fa_disable(request: Request, body: dict = Body(...)):
|
|||
@app.get("/admin/api/settings/bot")
|
||||
def admin_get_bot_settings(request: Request):
|
||||
require_admin(request)
|
||||
masked = f"{BOT_TOKEN[:8]}...{BOT_TOKEN[-4:]}" if len(BOT_TOKEN) > 14 else "***"
|
||||
return {"username": BOT_USERNAME, "token_masked": masked}
|
||||
bot_token, bot_username = settings.bot_credentials()
|
||||
masked = f"{bot_token[:8]}...{bot_token[-4:]}" if len(bot_token) > 14 else "***"
|
||||
return {"username": bot_username, "token_masked": masked}
|
||||
|
||||
|
||||
@app.post("/admin/api/settings/bot")
|
||||
|
|
@ -980,6 +984,7 @@ def admin_gift_codes(request: Request):
|
|||
codes = db.list_gift_codes()
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
plans_by_code = settings.get_plans_by_code()
|
||||
_, bot_username = settings.bot_credentials()
|
||||
out = []
|
||||
for c in codes:
|
||||
node = nodes_by_code.get(c["node"])
|
||||
|
|
@ -988,7 +993,7 @@ def admin_gift_codes(request: Request):
|
|||
**c,
|
||||
"node_label": node["label"] if node else c["node"],
|
||||
"plan_label": plan["label"] if plan else c["plan"],
|
||||
"link": f"https://t.me/{BOT_USERNAME}?start=gift_{c['code']}",
|
||||
"link": f"https://t.me/{bot_username}?start=gift_{c['code']}",
|
||||
})
|
||||
return out
|
||||
|
||||
|
|
@ -1000,7 +1005,8 @@ def admin_create_gift_code(request: Request, body: dict = Body(...)):
|
|||
if node not in {n["code"] for n in db.list_nodes()} or plan not in settings.get_plans_by_code():
|
||||
raise HTTPException(400, "invalid node/plan")
|
||||
code = db.create_gift_code(node, plan, created_by=0)
|
||||
return {"code": code, "link": f"https://t.me/{BOT_USERNAME}?start=gift_{code}"}
|
||||
_, bot_username = settings.bot_credentials()
|
||||
return {"code": code, "link": f"https://t.me/{bot_username}?start=gift_{code}"}
|
||||
|
||||
|
||||
@app.get("/admin/api/plans")
|
||||
|
|
@ -1148,11 +1154,11 @@ ADMIN_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "admi
|
|||
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root(request: Request):
|
||||
if request.headers.get("host", "").split(":")[0] == PANEL_DOMAIN:
|
||||
return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
||||
raise HTTPException(404)
|
||||
return legal.render_site_page("index.html")
|
||||
|
||||
|
||||
@app.get("/admin")
|
||||
|
|
@ -1160,6 +1166,11 @@ def admin_page():
|
|||
return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/cabinet.html", response_class=HTMLResponse)
|
||||
def cabinet_page():
|
||||
return legal.render_site_page("cabinet.html")
|
||||
|
||||
|
||||
@app.get("/offer", response_class=HTMLResponse)
|
||||
def offer_page():
|
||||
return legal.render("offer.html")
|
||||
|
|
@ -1168,3 +1179,28 @@ def offer_page():
|
|||
@app.get("/privacy", response_class=HTMLResponse)
|
||||
def privacy_page():
|
||||
return legal.render("privacy.html")
|
||||
|
||||
|
||||
@app.get("/api/plans")
|
||||
def public_plans():
|
||||
return {
|
||||
"payments_enabled": settings.get_payment_settings()["payments_enabled"],
|
||||
"plans": settings.get_plans(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/branding")
|
||||
def public_branding():
|
||||
return {"brand_name": settings.get_brand_name()}
|
||||
|
||||
|
||||
@app.post("/admin/api/branding")
|
||||
def admin_set_branding(request: Request, body: dict = Body(...)):
|
||||
require_admin(request)
|
||||
brand_name = (body.get("brand_name") or "").strip()
|
||||
if not brand_name:
|
||||
raise HTTPException(400, "название не может быть пустым")
|
||||
if len(brand_name) > 60:
|
||||
raise HTTPException(400, "слишком длинное название")
|
||||
_update_env_var("BRAND_NAME", brand_name)
|
||||
return {"brand_name": settings.get_brand_name()}
|
||||
|
|
|
|||
21
bot.py
21
bot.py
|
|
@ -72,13 +72,14 @@ def connect_kb(token: str, extra_rows: list[list[InlineKeyboardButton]] | None =
|
|||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
ABOUT_TEXT = (
|
||||
"<b>MBS Panel</b>\n\n"
|
||||
"Быстрый и незаметный доступ без границ. Протокол VLESS+Reality "
|
||||
"маскируется под обычный HTTPS-трафик, ничем не палится.\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Сайт: {SITE_DOMAIN}"
|
||||
)
|
||||
def about_text() -> str:
|
||||
return (
|
||||
f"<b>{settings.get_brand_name()}</b>\n\n"
|
||||
"Быстрый и незаметный доступ без границ. Протокол VLESS+Reality "
|
||||
"маскируется под обычный HTTPS-трафик, ничем не палится.\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Сайт: {SITE_DOMAIN}"
|
||||
)
|
||||
|
||||
|
||||
async def send_main_menu(message: Message):
|
||||
|
|
@ -121,7 +122,7 @@ async def start_deeplink(message: Message, command: CommandObject):
|
|||
async def start_plain(message: Message):
|
||||
db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||
await message.answer(
|
||||
"Привет! Это бот MBS Panel.\nВыбери действие ниже.",
|
||||
f"Привет! Это бот {settings.get_brand_name()}.\nВыбери действие ниже.",
|
||||
)
|
||||
await send_main_menu(message)
|
||||
|
||||
|
|
@ -135,7 +136,7 @@ async def cb_menu_main(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data == "menu:about")
|
||||
async def cb_about(cb: CallbackQuery):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||
await cb.message.edit_text(ABOUT_TEXT, reply_markup=kb)
|
||||
await cb.message.edit_text(about_text(), reply_markup=kb)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
|
|
@ -201,7 +202,7 @@ async def cb_pay(cb: CallbackQuery):
|
|||
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
|
||||
try:
|
||||
external_id, pay_url = payments.create_payment_link(
|
||||
provider, payment_id, plan["price"], f"MBS Panel — {node_row['label']}, {plan['label']}",
|
||||
provider, payment_id, plan["price"], f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("payment creation failed")
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ if ADMIN_PANEL_PASSWORD in ("change-me", "changeme", "admin", "password") or len
|
|||
PANEL_DOMAIN = env("PANEL_DOMAIN", required=True)
|
||||
SUB_DOMAIN = env("SUB_DOMAIN", required=True)
|
||||
SITE_DOMAIN = env("SITE_DOMAIN", required=True)
|
||||
BRAND_NAME = env("BRAND_NAME", "MBS Panel")
|
||||
|
||||
DB_PATH = os.path.join(BASE_DIR, "mbs.db")
|
||||
XRAY_CONFIG_PATH = "/usr/local/etc/xray/config.json"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ case "$ID" in
|
|||
*) echo "тестировалось на Ubuntu 22/24 и Debian 11/12, но пробуем всё равно на $PRETTY_NAME" ;;
|
||||
esac
|
||||
|
||||
BRAND_NAME=$(ask "Название твоего сервиса (видят клиенты — сайт/бот/подписка)" "MBS Panel")
|
||||
PANEL_DOMAIN=$(ask "Домен панели (админка)" "")
|
||||
SUB_DOMAIN=$(ask "Домен подписок" "")
|
||||
SITE_DOMAIN=$(ask "Домен сайта (для CORS и ссылок в боте)" "$PANEL_DOMAIN")
|
||||
|
|
@ -105,6 +106,7 @@ XRAY_SHORT_ID_GRPC=$(openssl rand -hex 8)
|
|||
XRAY_SHORT_ID_XHTTP=$(openssl rand -hex 8)
|
||||
|
||||
cat > "$APP_DIR/.env" << ENVEOF
|
||||
BRAND_NAME=$BRAND_NAME
|
||||
BOT_TOKEN=$BOT_TOKEN
|
||||
BOT_USERNAME=$BOT_USERNAME
|
||||
ADMIN_IDS=$ADMIN_IDS
|
||||
|
|
@ -418,6 +420,7 @@ echo "== готово =="
|
|||
echo "Панель: https://$PANEL_DOMAIN"
|
||||
echo "Пароль: $ADMIN_PANEL_PASSWORD (сменить: mbs pass)"
|
||||
echo "Подписки: https://$SUB_DOMAIN"
|
||||
echo "Сайт: https://$SUB_DOMAIN (готовый лендинг, название/тарифы уже подставлены — правь site/index.html под себя, если нужно)"
|
||||
echo "Нода: $DE1_ADDRESS"
|
||||
echo
|
||||
echo "статус сервисов:"
|
||||
|
|
|
|||
36
legal.py
36
legal.py
|
|
@ -1,10 +1,10 @@
|
|||
import html
|
||||
import os
|
||||
|
||||
from config import BASE_DIR, BOT_USERNAME
|
||||
import config
|
||||
|
||||
ENV_PATH = os.path.join(BASE_DIR, ".env")
|
||||
SITE_DIR = os.path.join(BASE_DIR, "site")
|
||||
ENV_PATH = os.path.join(config.BASE_DIR, ".env")
|
||||
SITE_DIR = os.path.join(config.BASE_DIR, "site")
|
||||
|
||||
FIELD_KEYS = ["LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL", "OFFER_EFFECTIVE_DATE"]
|
||||
|
||||
|
|
@ -60,20 +60,48 @@ def _field(value: str, fallback_label: str) -> str:
|
|||
return html.escape(value) if value else _fallback(fallback_label)
|
||||
|
||||
|
||||
def live_bot_username() -> str:
|
||||
raw = read_env_var("BOT_USERNAME", "")
|
||||
return raw.strip() if raw.strip() else config.BOT_USERNAME
|
||||
|
||||
|
||||
def live_brand_name() -> str:
|
||||
raw = read_env_var("BRAND_NAME", "")
|
||||
return raw.strip() if raw.strip() else config.BRAND_NAME
|
||||
|
||||
|
||||
def render(template_name: str) -> str:
|
||||
path = os.path.join(SITE_DIR, template_name)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
s = get_settings()
|
||||
bot_username = live_bot_username()
|
||||
replacements = {
|
||||
"EFFECTIVE_DATE": _field(s["OFFER_EFFECTIVE_DATE"], "дата не указана"),
|
||||
"LEGAL_NAME": _field(s["LEGAL_NAME"], "название/ФИО не указано"),
|
||||
"INN": _field(s["LEGAL_INN"], "ИНН не указан"),
|
||||
"BOT_USERNAME": _field(f"@{BOT_USERNAME}" if BOT_USERNAME else "", "бот не указан"),
|
||||
"BOT_USERNAME": _field(f"@{bot_username}" if bot_username else "", "бот не указан"),
|
||||
"REFUND_HOURS": html.escape(s["REFUND_HOURS"]) if s["REFUND_HOURS"] else "24",
|
||||
"SUPPORT_CONTACT": _field(s["SUPPORT_CONTACT"], "контакт не указан"),
|
||||
"SUPPORT_EMAIL": _field(s["SUPPORT_EMAIL"], "email не указан"),
|
||||
"BRAND_NAME": html.escape(live_brand_name()),
|
||||
}
|
||||
for token, value in replacements.items():
|
||||
content = content.replace("{{" + token + "}}", value)
|
||||
return content
|
||||
|
||||
|
||||
def render_site_page(template_name: str) -> str:
|
||||
path = os.path.join(SITE_DIR, template_name)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
replacements = {
|
||||
"BRAND_NAME": html.escape(live_brand_name()),
|
||||
"SITE_DOMAIN": html.escape(config.SITE_DOMAIN),
|
||||
"SUB_DOMAIN": html.escape(config.SUB_DOMAIN),
|
||||
"BOT_USERNAME": html.escape(live_bot_username()),
|
||||
}
|
||||
for token, value in replacements.items():
|
||||
content = content.replace("{{" + token + "}}", value)
|
||||
|
|
|
|||
13
settings.py
13
settings.py
|
|
@ -67,6 +67,19 @@ def platega_credentials():
|
|||
)
|
||||
|
||||
|
||||
def get_brand_name() -> str:
|
||||
raw = legal.read_env_var("BRAND_NAME", "")
|
||||
return raw.strip() if raw.strip() else config.BRAND_NAME
|
||||
|
||||
|
||||
def bot_credentials():
|
||||
raw = legal.read_env_vars(["BOT_TOKEN", "BOT_USERNAME"])
|
||||
return (
|
||||
raw.get("BOT_TOKEN") or config.BOT_TOKEN,
|
||||
raw.get("BOT_USERNAME") or config.BOT_USERNAME,
|
||||
)
|
||||
|
||||
|
||||
def get_hwid_settings() -> dict:
|
||||
raw = legal.read_env_vars(["HWID_LIMIT_ENABLED", "HWID_FALLBACK_LIMIT"])
|
||||
limit = _positive_int(raw.get("HWID_FALLBACK_LIMIT"), config.HWID_FALLBACK_LIMIT)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Личный кабинет — MBS Panel</title>
|
||||
<title>Личный кабинет — {{BRAND_NAME}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -81,11 +81,11 @@
|
|||
</div>
|
||||
<div id="err"></div>
|
||||
<div id="content"></div>
|
||||
<p class="hint">Токен выдаёт бот <a class="tglink" href="https://t.me/YourBot_robot" target="_blank">@YourBot_robot</a> — «Моя подписка»</p>
|
||||
<p class="hint">Токен выдаёт бот <a class="tglink" href="https://t.me/{{BOT_USERNAME}}" target="_blank">@{{BOT_USERNAME}}</a> — «Моя подписка»</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = "https://sub.example.com";
|
||||
const API = "https://{{SUB_DOMAIN}}";
|
||||
|
||||
function esc(s) {
|
||||
if (s === null || s === undefined) return "";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel</title>
|
||||
<title>{{BRAND_NAME}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">MBS Panel</div>
|
||||
<div class="logo">{{BRAND_NAME}}</div>
|
||||
<nav>
|
||||
<a href="#features">Возможности</a>
|
||||
<a href="#plans">Тарифы</a>
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
<section class="hero">
|
||||
<h1 class="reveal">Интернет без границ<br><span class="accent">и без замедлений</span></h1>
|
||||
<p class="reveal">Быстрый доступ к любимым сайтам и сервисам. Трафик не отличить от обычного HTTPS, скорость — на выделенных мощностях.</p>
|
||||
<a class="btn reveal" href="https://t.me/YourBot_robot" target="_blank">Получить доступ</a>
|
||||
<a class="btn reveal" href="https://t.me/{{BOT_USERNAME}}" target="_blank">Получить доступ</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
@ -164,23 +164,19 @@
|
|||
|
||||
<section class="plans" id="plans">
|
||||
<h2 class="reveal">Тарифы</h2>
|
||||
<div class="plan-row reveal">
|
||||
<div class="plan"><div class="d">7 дней</div><div class="l">пробный</div></div>
|
||||
<div class="plan"><div class="d">1 месяц</div><div class="l">стандарт</div></div>
|
||||
<div class="plan"><div class="d">3 месяца</div><div class="l">выгодно</div></div>
|
||||
<div class="plan"><div class="d">6 месяцев</div><div class="l">выгоднее</div></div>
|
||||
<div class="plan"><div class="d">1 год</div><div class="l">максимум</div></div>
|
||||
<div class="plan-row reveal" id="plan-row">
|
||||
<div class="plan"><div class="d">…</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cta reveal">
|
||||
<a class="btn ghost" href="https://t.me/YourBot_robot" target="_blank">Выбрать тариф в боте</a>
|
||||
<a class="btn ghost" href="https://t.me/{{BOT_USERNAME}}" target="_blank">Выбрать тариф в боте</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
example.com — <a href="/cabinet.html">личный кабинет</a> — подписка через <a href="https://sub.example.com" target="_blank">sub.example.com</a> — <a href="/offer.html">оферта</a> — <a href="/privacy.html">конфиденциальность</a>
|
||||
{{SITE_DOMAIN}} — <a href="/cabinet.html">личный кабинет</a> — подписка через <a href="https://{{SUB_DOMAIN}}" target="_blank">{{SUB_DOMAIN}}</a> — <a href="/offer">оферта</a> — <a href="/privacy">конфиденциальность</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
|
@ -195,6 +191,22 @@
|
|||
});
|
||||
}, { threshold: 0.15 });
|
||||
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
const PLAN_TAGLINES = { "7d": "пробный", "1m": "стандарт", "3m": "выгодно", "6m": "выгоднее", "1y": "максимум" };
|
||||
|
||||
fetch("/api/plans").then((r) => r.json()).then((data) => {
|
||||
const row = document.getElementById("plan-row");
|
||||
row.innerHTML = data.plans.map((p) => `
|
||||
<div class="plan">
|
||||
<div class="d">${esc(p.label)}</div>
|
||||
<div class="l">${data.payments_enabled && p.price > 0 ? esc(p.price) + " ₽" : esc(PLAN_TAGLINES[p.code] || "")}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}).catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — Публичная оферта</title>
|
||||
<title>{{BRAND_NAME}} — Публичная оферта</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — Политика конфиденциальности</title>
|
||||
<title>{{BRAND_NAME}} — Политика конфиденциальности</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
<h1>Политика конфиденциальности</h1>
|
||||
<p class="updated">Действует с {{EFFECTIVE_DATE}}</p>
|
||||
|
||||
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает {{LEGAL_NAME}} (далее — «Оператор») при использовании Telegram-бота и сайта MBS Panel.</p>
|
||||
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает {{LEGAL_NAME}} (далее — «Оператор») при использовании Telegram-бота и сайта {{BRAND_NAME}}.</p>
|
||||
|
||||
<h2>1. Какие данные собираются</h2>
|
||||
<ul>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue