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:
Savsis? 2026-09-13 23:52:54 +05:00
parent 7d140711fd
commit 7cc50973f6
14 changed files with 228 additions and 52 deletions

View file

@ -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

View file

@ -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

View file

@ -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`.

View file

@ -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">&nbsp;</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">&nbsp;</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
View file

@ -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()}

13
bot.py
View file

@ -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"
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")

View file

@ -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"

View file

@ -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 "статус сервисов:"

View file

@ -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)

View file

@ -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)

View file

@ -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 "";

View file

@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>

View file

@ -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;

View file

@ -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>