2026-09-11 22:20:54 +05:00
|
|
|
|
import asyncio
|
2026-09-10 17:45:43 +05:00
|
|
|
|
import datetime
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
2026-09-10 22:06:15 +05:00
|
|
|
|
import re
|
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
|
|
|
|
import secrets
|
2026-09-10 23:29:58 +05:00
|
|
|
|
import subprocess
|
|
|
|
|
|
import urllib.request
|
feat: backup & restore built into the admin panel
Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.
Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).
Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.
Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:23:38 +05:00
|
|
|
|
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
|
2026-09-10 17:45:43 +05:00
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
from fastapi import Body
|
|
|
|
|
|
from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
|
|
|
|
|
|
|
feat: backup & restore built into the admin panel
Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.
Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).
Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.
Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:23:38 +05:00
|
|
|
|
import backup
|
2026-09-10 17:45:43 +05:00
|
|
|
|
import db
|
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
|
|
|
|
import legal
|
2026-09-10 17:45:43 +05:00
|
|
|
|
import links
|
|
|
|
|
|
import nodeprov
|
2026-09-10 21:44:45 +05:00
|
|
|
|
import payments
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
import settings
|
2026-09-12 15:43:52 +05:00
|
|
|
|
import totp
|
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
|
|
|
|
import webhooks
|
2026-09-10 17:45:43 +05:00
|
|
|
|
import xray_manager
|
feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have
Pulled a fresh copy of docs.rw's own Remnawave-vs-Marzban comparison
table (not working from memory of an earlier read) to check what's
still genuinely different after tonight's run of fixes — most rows
already match or beat both panels (multi-admin, 2FA, HWID limits,
backup/restore, host sorting, config validation, node autonomy, on-hold
status as of a few commits ago). One concrete, bounded, unclaimed row:
"Security measures in documentation" lists CF zero trust / custom path
/ Telegram OAuth / 2FA for Remnawave, nothing for Marzban. We already
had 2FA and rate-limiting; custom path was the missing, actually
implementable piece — everything else in that row is deployment
guidance, not panel code.
New ADMIN_PATH env var (config.py, defaults to "admin" — every existing
install keeps working exactly as before with zero action needed). The
page-serving route moves to whatever path is configured; root() on
PANEL_DOMAIN only falls through to serving admin.html when ADMIN_PATH
is still the default, otherwise it shows the same branded landing page
every other domain gets — so a scanner or a human guessing "/admin"
finds nothing once this is set, not even a redirect that confirms
something lives there.
Deliberately scoped to ONLY the page route. /admin/api/* stays fixed —
it's already behind real cookie+session auth (verified this while
auditing: every mutating admin route either calls require_admin() or
the equivalent _require_current_admin(), checked programmatically via
ast rather than trusting my memory of having added the check everywhere
— found nothing actually missing, which is itself worth knowing, not
just assumed). Moving the API namespace too would be a much bigger,
riskier rewrite of every @app decorator in the file for no real security
gain over what auth already provides.
Deliberately NOT exposed in the Settings UI, unlike almost everything
else made live-editable tonight. This one genuinely needs a process
restart to take effect (FastAPI resolves routes at import time, not
per-request), and a typo saved through the UI followed by a restart
is a real self-lockout risk with no web-based way back — same tier as
PANEL_DOMAIN/SUB_DOMAIN, which are also .env-only for the same reason.
.env + SSH is the correct blast radius for a setting that can lock you
out.
Verification: config.py's normalization (strip slashes, empty/lone-
slash/repeated-slash input all falling back to "admin" rather than
accidentally producing a route at bare "/") tested directly — 8 cases.
AST-extracted the updated root() out of api.py (still can't import the
module locally) and exercised its actual branching with a mocked
FileResponse/legal/request — confirmed the default case is byte-for-
byte the old behavior and the custom-path case stops serving admin.html
on PANEL_DOMAIN's root. Added a dedicated CI step that does what only a
real FastAPI import can prove: with ADMIN_PATH set, /xyz123secret is a
registered route, plain /admin is NOT (not just supplemented — actually
gone), and /admin/api/login is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:58:45 +05:00
|
|
|
|
from config import SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, ADMIN_PATH, BASE_DIR
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
2026-09-10 22:06:15 +05:00
|
|
|
|
HWID_RE = re.compile(r"^[a-zA-Z0-9=-]{10,64}$")
|
2026-09-10 23:29:58 +05:00
|
|
|
|
ENV_PATH = os.path.join(BASE_DIR, ".env")
|
2026-09-10 22:06:15 +05:00
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
db.init_db()
|
|
|
|
|
|
|
2026-09-10 23:29:58 +05:00
|
|
|
|
|
|
|
|
|
|
def _update_env_var(key: str, value: str):
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
legal.update_env_var(key, value)
|
2026-09-10 23:29:58 +05:00
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
app = FastAPI(title="mbs-api")
|
|
|
|
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
|
allow_origins=[f"https://{SITE_DOMAIN}", f"https://www.{SITE_DOMAIN}", f"https://{PANEL_DOMAIN}"],
|
|
|
|
|
|
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
|
allow_credentials=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
ADMIN_COOKIE = "mbs_admin"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_admin(request: Request):
|
|
|
|
|
|
token = request.cookies.get(ADMIN_COOKIE)
|
|
|
|
|
|
if not db.validate_admin_session(token):
|
|
|
|
|
|
raise HTTPException(401, "unauthorized")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
|
|
|
|
def _days_left(sub: dict) -> int:
|
|
|
|
|
|
exp = datetime.datetime.fromisoformat(sub["expires_at"])
|
|
|
|
|
|
reference = datetime.datetime.fromisoformat(sub["held_at"]) if sub.get("held_at") else datetime.datetime.utcnow()
|
|
|
|
|
|
delta = exp - reference
|
2026-09-10 17:45:43 +05:00
|
|
|
|
return max(0, delta.days)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CLIENT_UA_MARKERS = (
|
|
|
|
|
|
"happ", "v2ray", "v2box", "nekoray", "nekobox", "clash", "hiddify",
|
|
|
|
|
|
"streisand", "shadowrocket", "sing-box", "singbox", "karing", "loon",
|
|
|
|
|
|
"quantumult", "surge", "stash",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_app_client(user_agent: str) -> bool:
|
|
|
|
|
|
ua = (user_agent or "").lower()
|
|
|
|
|
|
return any(m in ua for m in CLIENT_UA_MARKERS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SUB_PAGE_TEMPLATE = """<!doctype html>
|
|
|
|
|
|
<html lang="ru">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
<title>{brand_name} — подписка</title>
|
2026-09-10 17:45:43 +05:00
|
|
|
|
<style>
|
|
|
|
|
|
:root {{
|
|
|
|
|
|
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
|
|
|
|
|
--text: #eceef2; --muted: #868c99; --accent: #7c6cf0;
|
|
|
|
|
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
|
}}
|
|
|
|
|
|
* {{ box-sizing: border-box; }}
|
|
|
|
|
|
body {{
|
|
|
|
|
|
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
|
|
|
|
background: var(--bg); color: var(--text);
|
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
|
|
|
|
-webkit-font-smoothing: antialiased;
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.card {{
|
|
|
|
|
|
max-width: 400px; width: 100%; background: var(--card); border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 16px; padding: 32px 28px; text-align: center;
|
|
|
|
|
|
opacity: 0; transform: translateY(10px); filter: blur(6px);
|
|
|
|
|
|
animation: enter 0.6s var(--ease) forwards;
|
|
|
|
|
|
}}
|
|
|
|
|
|
@keyframes enter {{ to {{ opacity: 1; transform: translateY(0); filter: blur(0); }} }}
|
|
|
|
|
|
.badge {{
|
|
|
|
|
|
font-size: 12px; color: var(--muted); letter-spacing: 0.06em;
|
|
|
|
|
|
text-transform: uppercase; margin-bottom: 10px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
h1 {{ font-size: 20px; margin: 0 0 6px; font-weight: 600; letter-spacing: -0.01em; }}
|
|
|
|
|
|
p.sub {{ color: var(--muted); font-size: 14px; margin: 0 0 26px; line-height: 1.5; }}
|
|
|
|
|
|
.btn {{
|
|
|
|
|
|
display: block; width: 100%; padding: 14px 18px; border-radius: 10px;
|
|
|
|
|
|
background: var(--text); color: var(--bg); text-decoration: none;
|
|
|
|
|
|
font-weight: 600; font-size: 15px; margin-bottom: 10px; border: none; cursor: pointer;
|
|
|
|
|
|
transition: transform 0.15s var(--ease), opacity 0.15s var(--ease);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.btn:hover {{ opacity: 0.85; }}
|
|
|
|
|
|
.btn:active {{ transform: scale(0.97); }}
|
|
|
|
|
|
.btn.secondary {{ background: transparent; color: var(--text); border: 1px solid var(--border); }}
|
|
|
|
|
|
.btn.secondary:hover {{ opacity: 1; border-color: #333947; }}
|
|
|
|
|
|
.link-box {{
|
|
|
|
|
|
background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px;
|
|
|
|
|
|
font-size: 12px; color: var(--muted); word-break: break-all; margin-bottom: 22px; text-align: left;
|
|
|
|
|
|
opacity: 0; animation: fadeIn 0.5s var(--ease) 0.2s forwards;
|
|
|
|
|
|
}}
|
|
|
|
|
|
@keyframes fadeIn {{ to {{ opacity: 1; }} }}
|
|
|
|
|
|
.thanks {{ font-size: 13px; color: var(--muted); line-height: 1.5; }}
|
2026-09-10 22:46:55 +05:00
|
|
|
|
.thanks a {{ color: var(--accent); text-decoration: none; }}
|
|
|
|
|
|
.thanks a:hover {{ text-decoration: underline; }}
|
2026-09-10 17:45:43 +05:00
|
|
|
|
.qr-box {{
|
|
|
|
|
|
display: flex; justify-content: center; margin-bottom: 22px;
|
|
|
|
|
|
opacity: 0; animation: fadeIn 0.5s var(--ease) 0.3s forwards;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.qr-box img, .qr-box canvas {{ border-radius: 10px; background: #fff; padding: 8px; }}
|
|
|
|
|
|
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {{
|
|
|
|
|
|
*, *::before, *::after {{ animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }}
|
|
|
|
|
|
.card, .link-box, .qr-box {{ opacity: 1 !important; transform: none !important; filter: none !important; }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="card">
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
<div class="badge">{brand_name}</div>
|
2026-09-10 17:45:43 +05:00
|
|
|
|
<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>
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
<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>
|
2026-09-10 17:45:43 +05:00
|
|
|
|
</div>
|
|
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
|
|
|
|
|
<script>
|
|
|
|
|
|
new QRCode(document.getElementById("qr"), {{
|
|
|
|
|
|
text: "{sub_url}", width: 160, height: 160,
|
|
|
|
|
|
colorDark: "#0a0b0f", colorLight: "#ffffff", correctLevel: QRCode.CorrectLevel.M,
|
|
|
|
|
|
}});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SUB_PAGE_EXPIRED_TEMPLATE = """<!doctype html>
|
|
|
|
|
|
<html lang="ru">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
<title>{brand_name} — подписка</title>
|
2026-09-10 17:45:43 +05:00
|
|
|
|
<style>
|
|
|
|
|
|
:root {{
|
|
|
|
|
|
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
|
|
|
|
|
--text: #eceef2; --muted: #868c99; --accent: #7c6cf0; --red: #e5686b;
|
|
|
|
|
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
|
}}
|
|
|
|
|
|
* {{ box-sizing: border-box; }}
|
|
|
|
|
|
body {{
|
|
|
|
|
|
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
|
|
|
|
background: var(--bg); color: var(--text);
|
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
|
|
|
|
-webkit-font-smoothing: antialiased; padding: 24px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.card {{
|
|
|
|
|
|
max-width: 400px; width: 100%; background: var(--card); border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 16px; padding: 32px 28px; text-align: center;
|
|
|
|
|
|
opacity: 0; transform: translateY(10px); filter: blur(6px);
|
|
|
|
|
|
animation: enter 0.6s var(--ease) forwards;
|
|
|
|
|
|
}}
|
|
|
|
|
|
@keyframes enter {{ to {{ opacity: 1; transform: translateY(0); filter: blur(0); }} }}
|
|
|
|
|
|
.badge {{ font-size: 12px; color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 10px; }}
|
|
|
|
|
|
h1 {{ font-size: 20px; margin: 0 0 6px; font-weight: 600; letter-spacing: -0.01em; color: var(--red); }}
|
|
|
|
|
|
p.sub {{ color: var(--muted); font-size: 14px; margin: 0 0 26px; line-height: 1.5; }}
|
|
|
|
|
|
.btn {{
|
|
|
|
|
|
display: block; width: 100%; padding: 14px 18px; border-radius: 10px;
|
|
|
|
|
|
background: var(--text); color: var(--bg); text-decoration: none;
|
|
|
|
|
|
font-weight: 600; font-size: 15px; border: none; cursor: pointer;
|
|
|
|
|
|
transition: transform 0.15s var(--ease), opacity 0.15s var(--ease);
|
|
|
|
|
|
}}
|
|
|
|
|
|
.btn:hover {{ opacity: 0.85; }}
|
|
|
|
|
|
.btn:active {{ transform: scale(0.97); }}
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {{
|
|
|
|
|
|
*, *::before, *::after {{ animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }}
|
|
|
|
|
|
.card {{ opacity: 1 !important; transform: none !important; filter: none !important; }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="card">
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
<div class="badge">{brand_name}</div>
|
2026-09-10 17:45:43 +05:00
|
|
|
|
<h1>Подписка истекла</h1>
|
|
|
|
|
|
<p class="sub">Доступ по этой ссылке закончился. Продли подписку в боте — ссылка останется той же, ничего заново настраивать не нужно.</p>
|
|
|
|
|
|
<a class="btn" href="https://t.me/{bot_username}" target="_blank">Продлить в боте</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/healthz")
|
|
|
|
|
|
def healthz():
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/sub/{token}")
|
|
|
|
|
|
def get_subscription(token: str, request: Request):
|
|
|
|
|
|
user = db.get_user_by_token(token)
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
subs = db.list_active_subscriptions(tg_id=user["tg_id"])
|
|
|
|
|
|
ua = request.headers.get("user-agent", "")
|
|
|
|
|
|
if not _is_app_client(ua):
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
brand_name = settings.get_brand_name()
|
2026-09-10 17:45:43 +05:00
|
|
|
|
if not subs:
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
_, bot_username = settings.bot_credentials()
|
|
|
|
|
|
return HTMLResponse(SUB_PAGE_EXPIRED_TEMPLATE.format(bot_username=bot_username, brand_name=brand_name))
|
2026-09-10 17:45:43 +05:00
|
|
|
|
sub_url = f"https://{SUB_DOMAIN}/sub/{token}"
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url, brand_name=brand_name))
|
2026-09-10 22:06:15 +05:00
|
|
|
|
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
hwid_cfg = settings.get_hwid_settings()
|
|
|
|
|
|
if hwid_cfg["enabled"]:
|
2026-09-10 22:06:15 +05:00
|
|
|
|
hwid = request.headers.get("x-hwid", "")
|
|
|
|
|
|
if not HWID_RE.match(hwid):
|
|
|
|
|
|
raise HTTPException(404, "hwid required")
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
limit = user["hwid_limit"] if user["hwid_limit"] is not None else hwid_cfg["fallback_limit"]
|
2026-09-11 22:20:54 +05:00
|
|
|
|
_, allowed = db.add_device_if_under_limit(
|
|
|
|
|
|
user["tg_id"], hwid, limit,
|
|
|
|
|
|
request.headers.get("x-device-os"),
|
|
|
|
|
|
request.headers.get("x-device-model"),
|
|
|
|
|
|
ua,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not allowed:
|
|
|
|
|
|
raise HTTPException(404, "device limit reached", headers={"x-hwid-max-devices-reached": "true"})
|
2026-09-10 22:06:15 +05:00
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
content = links.build_subscription_text(subs)
|
|
|
|
|
|
return Response(content=content, media_type="text/plain")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/cabinet/{token}")
|
|
|
|
|
|
def cabinet(token: str):
|
|
|
|
|
|
user = db.get_user_by_token(token)
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
subs = db.list_active_subscriptions(tg_id=user["tg_id"])
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plans_by_code = settings.get_plans_by_code()
|
2026-09-10 17:45:43 +05:00
|
|
|
|
out = []
|
|
|
|
|
|
for s in subs:
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = plans_by_code.get(s["plan"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
out.append({
|
|
|
|
|
|
"node": s["node"],
|
|
|
|
|
|
"plan": s["plan"],
|
|
|
|
|
|
"plan_label": plan["label"] if plan else s["plan"],
|
|
|
|
|
|
"expires_at": s["expires_at"],
|
feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
|
|
|
|
"days_left": _days_left(s),
|
2026-09-10 17:45:43 +05:00
|
|
|
|
})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"username": user["username"],
|
|
|
|
|
|
"subscriptions": out,
|
|
|
|
|
|
"sub_link": f"https://{SUB_DOMAIN}/sub/{token}",
|
|
|
|
|
|
"nodes_available": [n["label"] for n in db.list_nodes(enabled_only=True)],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/mgmt-pubkey.txt", response_class=PlainTextResponse)
|
|
|
|
|
|
def mgmt_pubkey():
|
|
|
|
|
|
return nodeprov.ensure_mgmt_key() + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/install/{token}.sh", response_class=PlainTextResponse)
|
|
|
|
|
|
def install_script(token: str):
|
|
|
|
|
|
node = db.get_node_by_token(token)
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
raise HTTPException(404, "unknown token")
|
|
|
|
|
|
return nodeprov.render_install_script(node)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 21:44:45 +05:00
|
|
|
|
def _tg_send_message(tg_id: int, text: str):
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
bot_token, _ = settings.bot_credentials()
|
|
|
|
|
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
2026-09-10 21:44:45 +05:00
|
|
|
|
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:
|
|
|
|
|
|
urllib.request.urlopen(req, timeout=10)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _grant_paid_subscription(payment_id: str):
|
2026-09-11 22:20:54 +05:00
|
|
|
|
payment = db.get_payment(payment_id)
|
|
|
|
|
|
if not payment or payment["status"] == "paid":
|
2026-09-10 21:44:45 +05:00
|
|
|
|
return
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = settings.get_plans_by_code().get(payment["plan"])
|
2026-09-10 21:44:45 +05:00
|
|
|
|
node = db.get_node(payment["node"])
|
|
|
|
|
|
if not plan or not node:
|
|
|
|
|
|
return
|
2026-09-11 22:20:54 +05:00
|
|
|
|
payment = db.mark_payment_paid(payment_id)
|
|
|
|
|
|
if not payment:
|
|
|
|
|
|
return
|
2026-09-10 21:44:45 +05:00
|
|
|
|
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
|
|
|
|
|
|
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
|
|
|
|
|
|
user = db.get_or_create_user(payment["tg_id"], None)
|
|
|
|
|
|
_tg_send_message(
|
|
|
|
|
|
payment["tg_id"],
|
|
|
|
|
|
f"<b>Оплата получена</b>\n\n"
|
|
|
|
|
|
f"Сервер: {node['label']}\n"
|
|
|
|
|
|
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
|
|
|
|
|
|
f"Ссылка-подписка:\nhttps://{SUB_DOMAIN}/sub/{user['token']}",
|
|
|
|
|
|
)
|
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
|
|
|
|
webhooks.send("payment.paid", {
|
|
|
|
|
|
"tg_id": payment["tg_id"],
|
|
|
|
|
|
"amount": payment["amount"],
|
|
|
|
|
|
"provider": payment["provider"],
|
|
|
|
|
|
"node": payment["node"],
|
|
|
|
|
|
"plan": payment["plan"],
|
|
|
|
|
|
"subscription_uuid": sub["uuid"],
|
|
|
|
|
|
"expires_at": sub["expires_at"],
|
|
|
|
|
|
})
|
2026-09-10 21:44:45 +05:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:46:55 +05:00
|
|
|
|
def _check_and_reconcile_payment(payment: dict) -> str:
|
|
|
|
|
|
if not payment.get("external_id"):
|
|
|
|
|
|
return payment["status"]
|
|
|
|
|
|
try:
|
|
|
|
|
|
status = payments.check_payment_status(payment["provider"], payment["external_id"])
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return payment["status"]
|
|
|
|
|
|
if status in payments.PAID_STATUSES:
|
|
|
|
|
|
_grant_paid_subscription(payment["id"])
|
|
|
|
|
|
return "paid"
|
|
|
|
|
|
if status in payments.FAILED_STATUSES:
|
|
|
|
|
|
db.mark_payment_failed(payment["id"])
|
|
|
|
|
|
return "failed"
|
|
|
|
|
|
return payment["status"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/payments")
|
|
|
|
|
|
def admin_list_payments(request: Request):
|
|
|
|
|
|
require_admin(request)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plans_by_code = settings.get_plans_by_code()
|
2026-09-10 22:46:55 +05:00
|
|
|
|
out = []
|
|
|
|
|
|
for p in db.list_payments():
|
2026-09-11 22:20:54 +05:00
|
|
|
|
node = nodes_by_code.get(p["node"])
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = plans_by_code.get(p["plan"])
|
2026-09-10 22:46:55 +05:00
|
|
|
|
out.append({
|
|
|
|
|
|
**p,
|
|
|
|
|
|
"node_label": node["label"] if node else p["node"],
|
|
|
|
|
|
"plan_label": plan["label"] if plan else p["plan"],
|
|
|
|
|
|
"provider_label": payments.PROVIDER_NAMES.get(p["provider"], p["provider"]),
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/payments/{payment_id}/check")
|
|
|
|
|
|
def admin_check_payment(payment_id: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
payment = db.get_payment(payment_id)
|
|
|
|
|
|
if not payment:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
if payment["status"] != "pending":
|
|
|
|
|
|
return {"status": payment["status"]}
|
|
|
|
|
|
status = _check_and_reconcile_payment(payment)
|
|
|
|
|
|
return {"status": status}
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
|
|
|
|
@app.get("/admin/api/payments/legal-settings")
|
|
|
|
|
|
def admin_get_legal_settings(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
return legal.get_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/payments/legal-settings")
|
|
|
|
|
|
def admin_set_legal_settings(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
for key in ("LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL"):
|
|
|
|
|
|
if key in body:
|
|
|
|
|
|
_update_env_var(key, str(body[key]).strip())
|
|
|
|
|
|
if not legal.read_env_var("OFFER_EFFECTIVE_DATE"):
|
|
|
|
|
|
_update_env_var("OFFER_EFFECTIVE_DATE", datetime.date.today().strftime("%d.%m.%Y"))
|
|
|
|
|
|
return {"ok": True, "settings": legal.get_settings()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/payments/yookassa-settings")
|
|
|
|
|
|
def admin_get_yookassa_settings(request: Request):
|
|
|
|
|
|
require_admin(request)
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
shop_id, secret_key = settings.yookassa_credentials()
|
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
|
|
|
|
return {
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
"enabled": settings.get_payment_settings()["yookassa_enabled"],
|
|
|
|
|
|
"shop_id": shop_id,
|
|
|
|
|
|
"has_secret": bool(secret_key),
|
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/payments/yookassa-settings")
|
|
|
|
|
|
def admin_set_yookassa_settings(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
shop_id = (body.get("shop_id") or "").strip()
|
|
|
|
|
|
secret_key = (body.get("secret_key") or "").strip()
|
|
|
|
|
|
if not shop_id or not secret_key:
|
|
|
|
|
|
raise HTTPException(400, "shop_id и secret_key обязательны")
|
|
|
|
|
|
try:
|
|
|
|
|
|
payments.validate_yookassa_credentials(shop_id, secret_key)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
raise HTTPException(401, "ЮKassa не приняла эти ключи — проверь shop_id и секретный ключ")
|
|
|
|
|
|
_update_env_var("YOOKASSA_SHOP_ID", shop_id)
|
|
|
|
|
|
_update_env_var("YOOKASSA_SECRET_KEY", secret_key)
|
|
|
|
|
|
_update_env_var("YOOKASSA_ENABLED", "true")
|
|
|
|
|
|
_update_env_var("PAYMENTS_ENABLED", "true")
|
|
|
|
|
|
restarted = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(["systemctl", "restart", "mbs-bot"], check=True, timeout=15)
|
2026-09-13 19:50:54 +05:00
|
|
|
|
restarted = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
restarted = False
|
|
|
|
|
|
return {"ok": True, "restarted_bot": restarted}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/payments/platega-settings")
|
|
|
|
|
|
def admin_get_platega_settings(request: Request):
|
|
|
|
|
|
require_admin(request)
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
merchant_id, secret = settings.platega_credentials()
|
2026-09-13 19:50:54 +05:00
|
|
|
|
return {
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
"enabled": settings.get_payment_settings()["platega_enabled"],
|
|
|
|
|
|
"merchant_id": merchant_id,
|
|
|
|
|
|
"has_secret": bool(secret),
|
2026-09-13 19:50:54 +05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/payments/platega-settings")
|
|
|
|
|
|
def admin_set_platega_settings(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
merchant_id = (body.get("merchant_id") or "").strip()
|
|
|
|
|
|
secret = (body.get("secret") or "").strip()
|
|
|
|
|
|
if not merchant_id or not secret:
|
|
|
|
|
|
raise HTTPException(400, "merchant_id и secret обязательны")
|
|
|
|
|
|
_update_env_var("PLATEGA_MERCHANT_ID", merchant_id)
|
|
|
|
|
|
_update_env_var("PLATEGA_SECRET", secret)
|
|
|
|
|
|
_update_env_var("PLATEGA_ENABLED", "true")
|
|
|
|
|
|
_update_env_var("PAYMENTS_ENABLED", "true")
|
|
|
|
|
|
restarted = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(["systemctl", "restart", "mbs-bot"], check=True, timeout=15)
|
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
|
|
|
|
restarted = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
restarted = False
|
|
|
|
|
|
return {"ok": True, "restarted_bot": restarted}
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
@app.get("/admin/api/payments/plan-settings")
|
|
|
|
|
|
def admin_get_plan_settings(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"payments_enabled": settings.get_payment_settings()["payments_enabled"],
|
|
|
|
|
|
"plans": settings.get_plans(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/payments/plan-settings")
|
|
|
|
|
|
def admin_set_plan_settings(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
prices = body.get("prices") or {}
|
|
|
|
|
|
known_codes = settings.PRICE_ENV_KEYS.keys()
|
|
|
|
|
|
clean_prices = {}
|
|
|
|
|
|
for code, value in prices.items():
|
|
|
|
|
|
if code not in known_codes:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
price = int(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
raise HTTPException(400, f"цена для тарифа {code} должна быть целым числом")
|
|
|
|
|
|
if price < 0:
|
|
|
|
|
|
raise HTTPException(400, f"цена для тарифа {code} не может быть отрицательной")
|
|
|
|
|
|
clean_prices[code] = price
|
|
|
|
|
|
settings.set_plan_prices(clean_prices)
|
|
|
|
|
|
if "payments_enabled" in body:
|
|
|
|
|
|
_update_env_var("PAYMENTS_ENABLED", "true" if body["payments_enabled"] else "false")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"payments_enabled": settings.get_payment_settings()["payments_enabled"],
|
|
|
|
|
|
"plans": settings.get_plans(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/hwid-settings")
|
|
|
|
|
|
def admin_get_hwid_settings(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
return settings.get_hwid_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/hwid-settings")
|
|
|
|
|
|
def admin_set_hwid_settings(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
if "enabled" in body:
|
|
|
|
|
|
_update_env_var("HWID_LIMIT_ENABLED", "true" if body["enabled"] else "false")
|
|
|
|
|
|
if "fallback_limit" in body:
|
|
|
|
|
|
try:
|
|
|
|
|
|
limit = int(body["fallback_limit"])
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
raise HTTPException(400, "лимит устройств должен быть целым числом")
|
|
|
|
|
|
if not (1 <= limit <= 1000):
|
|
|
|
|
|
raise HTTPException(400, "лимит устройств должен быть от 1 до 1000")
|
|
|
|
|
|
_update_env_var("HWID_FALLBACK_LIMIT", str(limit))
|
|
|
|
|
|
return settings.get_hwid_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
|
|
|
|
@app.get("/admin/api/webhook-settings")
|
|
|
|
|
|
def admin_get_webhook_settings(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"url": legal.read_env_var("WEBHOOK_URL", ""),
|
|
|
|
|
|
"secret": legal.read_env_var("WEBHOOK_SECRET", ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/webhook-settings")
|
|
|
|
|
|
def admin_set_webhook_settings(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
url = (body.get("url") or "").strip()
|
|
|
|
|
|
_update_env_var("WEBHOOK_URL", url)
|
|
|
|
|
|
if url and not legal.read_env_var("WEBHOOK_SECRET", ""):
|
|
|
|
|
|
_update_env_var("WEBHOOK_SECRET", secrets.token_hex(24))
|
|
|
|
|
|
return {"url": legal.read_env_var("WEBHOOK_URL", ""), "secret": legal.read_env_var("WEBHOOK_SECRET", "")}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 21:44:45 +05:00
|
|
|
|
@app.post("/payments/webhook/yookassa")
|
|
|
|
|
|
async def yookassa_webhook(request: Request):
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
if not payments.verify_yookassa_notification(body):
|
|
|
|
|
|
raise HTTPException(400, "unexpected event")
|
|
|
|
|
|
obj = body.get("object", {})
|
|
|
|
|
|
payment_id = (obj.get("metadata") or {}).get("payment_id")
|
|
|
|
|
|
if not payment_id:
|
|
|
|
|
|
raise HTTPException(400, "missing payment_id")
|
2026-09-11 22:20:54 +05:00
|
|
|
|
payment = db.get_payment(payment_id)
|
|
|
|
|
|
if not payment or not payment.get("external_id"):
|
|
|
|
|
|
raise HTTPException(400, "unknown payment")
|
|
|
|
|
|
status = await asyncio.to_thread(payments.check_yookassa_payment, payment["external_id"])
|
|
|
|
|
|
if status not in payments.PAID_STATUSES:
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
await asyncio.to_thread(_grant_paid_subscription, payment_id)
|
2026-09-10 21:44:45 +05:00
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/payments/webhook/platega")
|
|
|
|
|
|
async def platega_webhook(request: Request):
|
|
|
|
|
|
raw = await request.body()
|
|
|
|
|
|
signature = request.headers.get("x-signature") or request.headers.get("signature") or ""
|
|
|
|
|
|
if not payments.verify_platega_signature(raw, signature):
|
|
|
|
|
|
raise HTTPException(401, "bad signature")
|
|
|
|
|
|
body = json.loads(raw)
|
|
|
|
|
|
status = (body.get("status") or "").lower()
|
|
|
|
|
|
payment_id = body.get("id") or body.get("paymentId")
|
|
|
|
|
|
if status not in ("succeeded", "success", "paid") or not payment_id:
|
|
|
|
|
|
return {"ok": True}
|
2026-09-11 22:20:54 +05:00
|
|
|
|
await asyncio.to_thread(_grant_paid_subscription, payment_id)
|
2026-09-10 21:44:45 +05:00
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/pay/done", response_class=HTMLResponse)
|
|
|
|
|
|
def pay_done():
|
|
|
|
|
|
return (
|
|
|
|
|
|
"<!doctype html><html lang='ru'><head><meta charset='utf-8'>"
|
|
|
|
|
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
|
|
|
|
|
"<title>Оплата</title></head>"
|
|
|
|
|
|
"<body style='background:#0a0b0f;color:#eceef2;font-family:sans-serif;"
|
|
|
|
|
|
"display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center'>"
|
|
|
|
|
|
"<div><h2>Спасибо!</h2><p>Возвращайся в Telegram — подписка придёт туда автоматически "
|
|
|
|
|
|
"в течение минуты после подтверждения оплаты.</p></div></body></html>"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
@app.post("/nodes/register/{token}")
|
|
|
|
|
|
async def register_node(token: str, request: Request):
|
|
|
|
|
|
node = db.get_node_by_token(token)
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
raise HTTPException(404, "unknown token")
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
body = {}
|
|
|
|
|
|
if body.get("status") == "active":
|
|
|
|
|
|
db.activate_node(node["code"])
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
def _client_ip(request: Request) -> str:
|
|
|
|
|
|
return request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
@app.post("/admin/api/login")
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
def admin_login(request: Request, response: Response, body: dict = Body(...)):
|
|
|
|
|
|
ip = _client_ip(request)
|
|
|
|
|
|
if db.count_recent_login_attempts(ip, "password", minutes=15) >= 10:
|
|
|
|
|
|
raise HTTPException(429, "too many attempts, try again later")
|
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
|
|
|
|
username = (body.get("username") or "").strip()
|
|
|
|
|
|
password = body.get("password") or ""
|
|
|
|
|
|
admin = db.verify_admin_login(username, password)
|
|
|
|
|
|
if not admin:
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
db.record_login_attempt(ip, "password")
|
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
|
|
|
|
raise HTTPException(401, "wrong username or password")
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
db.clear_login_attempts(ip, "password")
|
2026-09-12 15:43:52 +05:00
|
|
|
|
if admin.get("totp_secret"):
|
|
|
|
|
|
pending_token = db.create_pending_totp(admin["id"])
|
|
|
|
|
|
return {"ok": True, "needs_totp": True, "pending_token": pending_token}
|
|
|
|
|
|
token = db.create_admin_session(admin["id"])
|
|
|
|
|
|
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/login/totp")
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
def admin_login_totp(request: Request, response: Response, body: dict = Body(...)):
|
|
|
|
|
|
ip = _client_ip(request)
|
|
|
|
|
|
if db.count_recent_login_attempts(ip, "totp", minutes=5) >= 10:
|
|
|
|
|
|
raise HTTPException(429, "too many attempts, try again later")
|
2026-09-12 15:43:52 +05:00
|
|
|
|
pending_token = body.get("pending_token") or ""
|
|
|
|
|
|
code = (body.get("code") or "").strip()
|
|
|
|
|
|
pending = db.resolve_pending_totp(pending_token)
|
|
|
|
|
|
if not pending:
|
|
|
|
|
|
raise HTTPException(401, "login session expired, log in again")
|
|
|
|
|
|
admin = db.get_admin_by_id(pending["admin_id"])
|
|
|
|
|
|
if not admin or not admin.get("totp_secret") or not totp.verify(admin["totp_secret"], code):
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
db.record_login_attempt(ip, "totp")
|
2026-09-12 15:43:52 +05:00
|
|
|
|
raise HTTPException(401, "wrong code")
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
db.clear_login_attempts(ip, "totp")
|
2026-09-12 15:43:52 +05:00
|
|
|
|
db.delete_pending_totp(pending_token)
|
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
|
|
|
|
token = db.create_admin_session(admin["id"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/logout")
|
|
|
|
|
|
def admin_logout(request: Request, response: Response):
|
|
|
|
|
|
token = request.cookies.get(ADMIN_COOKIE)
|
|
|
|
|
|
if token:
|
|
|
|
|
|
db.delete_admin_session(token)
|
|
|
|
|
|
response.delete_cookie(ADMIN_COOKIE)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/me")
|
|
|
|
|
|
def admin_me(request: Request):
|
|
|
|
|
|
token = request.cookies.get(ADMIN_COOKIE)
|
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
|
|
|
|
admin = db.get_session_admin(token)
|
|
|
|
|
|
return {"authenticated": admin is not None, "username": admin["username"] if admin else None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/admins")
|
|
|
|
|
|
def admin_list_admins(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
return db.list_admins()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/admins")
|
|
|
|
|
|
def admin_create_admin(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
username = (body.get("username") or "").strip()
|
|
|
|
|
|
password = body.get("password") or ""
|
|
|
|
|
|
if len(username) < 3:
|
|
|
|
|
|
raise HTTPException(400, "username too short")
|
|
|
|
|
|
if len(password) < 8:
|
|
|
|
|
|
raise HTTPException(400, "password too short")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return db.create_admin(username, password)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.delete("/admin/api/admins/{admin_id}")
|
|
|
|
|
|
def admin_delete_admin(admin_id: int, request: Request):
|
2026-09-12 15:43:52 +05:00
|
|
|
|
current = _require_current_admin(request)
|
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
|
|
|
|
if current["id"] == admin_id:
|
|
|
|
|
|
raise HTTPException(400, "cannot delete your own account while logged in as it")
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.delete_admin(admin_id)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
return {"ok": True}
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 15:43:52 +05:00
|
|
|
|
def _require_current_admin(request: Request):
|
|
|
|
|
|
token = request.cookies.get(ADMIN_COOKIE)
|
|
|
|
|
|
current = db.get_session_admin(token)
|
|
|
|
|
|
if not current:
|
|
|
|
|
|
raise HTTPException(401, "unauthorized")
|
|
|
|
|
|
return current
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/2fa/status")
|
|
|
|
|
|
def admin_2fa_status(request: Request):
|
|
|
|
|
|
current = _require_current_admin(request)
|
|
|
|
|
|
admin = db.get_admin_by_id(current["id"])
|
|
|
|
|
|
return {"enabled": bool(admin and admin.get("totp_secret"))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/2fa/setup")
|
|
|
|
|
|
def admin_2fa_setup(request: Request):
|
|
|
|
|
|
current = _require_current_admin(request)
|
|
|
|
|
|
secret = totp.generate_secret()
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
return {"secret": secret, "uri": totp.uri(secret, current["username"], issuer=settings.get_brand_name())}
|
2026-09-12 15:43:52 +05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/2fa/enable")
|
|
|
|
|
|
def admin_2fa_enable(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
current = _require_current_admin(request)
|
|
|
|
|
|
secret = body.get("secret") or ""
|
|
|
|
|
|
code = (body.get("code") or "").strip()
|
|
|
|
|
|
if not secret or not totp.verify(secret, code):
|
|
|
|
|
|
raise HTTPException(400, "wrong code")
|
|
|
|
|
|
db.set_admin_totp_secret(current["id"], secret)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/2fa/disable")
|
|
|
|
|
|
def admin_2fa_disable(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
current = _require_current_admin(request)
|
|
|
|
|
|
password = body.get("password") or ""
|
|
|
|
|
|
if not db.verify_admin_password_by_id(current["id"], password):
|
|
|
|
|
|
raise HTTPException(401, "wrong password")
|
|
|
|
|
|
db.set_admin_totp_secret(current["id"], None)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
2026-09-10 23:29:58 +05:00
|
|
|
|
@app.get("/admin/api/settings/bot")
|
|
|
|
|
|
def admin_get_bot_settings(request: Request):
|
|
|
|
|
|
require_admin(request)
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
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}
|
2026-09-10 23:29:58 +05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/settings/bot")
|
|
|
|
|
|
def admin_set_bot_settings(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
token = (body.get("token") or "").strip()
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
raise HTTPException(400, "token required")
|
|
|
|
|
|
try:
|
|
|
|
|
|
with urllib.request.urlopen(f"https://api.telegram.org/bot{token}/getMe", timeout=10) as resp:
|
|
|
|
|
|
data = json.loads(resp.read())
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
raise HTTPException(400, "не получилось проверить токен — нет связи с Telegram")
|
|
|
|
|
|
if not data.get("ok"):
|
|
|
|
|
|
raise HTTPException(400, "Telegram отклонил этот токен")
|
|
|
|
|
|
username = data["result"]["username"]
|
|
|
|
|
|
_update_env_var("BOT_TOKEN", token)
|
|
|
|
|
|
_update_env_var("BOT_USERNAME", username)
|
|
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(["systemctl", "restart", "mbs-bot"], check=True, timeout=15)
|
|
|
|
|
|
restarted = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
restarted = False
|
|
|
|
|
|
return {"ok": True, "username": username, "restarted": restarted}
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: backup & restore built into the admin panel
Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.
Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).
Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.
Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:23:38 +05:00
|
|
|
|
@app.get("/admin/api/backup")
|
|
|
|
|
|
def admin_download_backup(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
data = backup.create_backup()
|
|
|
|
|
|
filename = f"mbs-backup-{datetime.datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.tar.gz"
|
|
|
|
|
|
return Response(
|
|
|
|
|
|
content=data, media_type="application/gzip",
|
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/backup/restore")
|
|
|
|
|
|
async def admin_restore_backup(request: Request, file: UploadFile = File(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
data = await file.read()
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = backup.restore_backup(data)
|
|
|
|
|
|
except backup.RestoreError as e:
|
|
|
|
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
restarted_bot = False
|
|
|
|
|
|
if result["restored_env"]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(["systemctl", "restart", "mbs-bot"], check=True, timeout=15)
|
|
|
|
|
|
restarted_bot = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
restarted_bot = False
|
|
|
|
|
|
return {"ok": True, **result, "restarted_bot": restarted_bot}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
@app.get("/admin/api/stats")
|
|
|
|
|
|
def admin_stats(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
return db.stats()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_bytes(n: int) -> str:
|
|
|
|
|
|
v = float(n)
|
|
|
|
|
|
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
|
|
|
|
if v < 1024 or unit == "TB":
|
|
|
|
|
|
return f"{v:.1f} {unit}" if unit != "B" else f"{int(v)} {unit}"
|
|
|
|
|
|
v /= 1024
|
|
|
|
|
|
return f"{v:.1f} TB"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/traffic")
|
|
|
|
|
|
def admin_traffic(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
all_stats = dict(xray_manager.query_stats())
|
|
|
|
|
|
for node in db.list_nodes():
|
|
|
|
|
|
if node["kind"] != "managed" or node["status"] != "active":
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
remote = nodeprov.remote_query_stats(node)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
remote = {}
|
|
|
|
|
|
for k, v in remote.items():
|
|
|
|
|
|
if k in all_stats:
|
|
|
|
|
|
all_stats[k]["up"] += v["up"]
|
|
|
|
|
|
all_stats[k]["down"] += v["down"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
all_stats[k] = v
|
|
|
|
|
|
|
|
|
|
|
|
total_up = sum(v["up"] for v in all_stats.values())
|
|
|
|
|
|
total_down = sum(v["down"] for v in all_stats.values())
|
|
|
|
|
|
|
|
|
|
|
|
subs = db.list_all_subscriptions(limit=5000)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
2026-09-10 17:45:43 +05:00
|
|
|
|
per_sub = []
|
|
|
|
|
|
for s in subs:
|
|
|
|
|
|
st = all_stats.get(s["uuid"])
|
|
|
|
|
|
if not st:
|
|
|
|
|
|
continue
|
2026-09-11 22:20:54 +05:00
|
|
|
|
node = nodes_by_code.get(s["node"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
per_sub.append({
|
|
|
|
|
|
"uuid": s["uuid"],
|
|
|
|
|
|
"username": ("@" + s["username"]) if s.get("username") else f"tg{s['tg_id']}",
|
|
|
|
|
|
"node_label": node["label"] if node else s["node"],
|
|
|
|
|
|
"up": st["up"], "down": st["down"],
|
|
|
|
|
|
"up_fmt": _fmt_bytes(st["up"]), "down_fmt": _fmt_bytes(st["down"]),
|
|
|
|
|
|
"total_fmt": _fmt_bytes(st["up"] + st["down"]),
|
|
|
|
|
|
})
|
|
|
|
|
|
per_sub.sort(key=lambda r: r["up"] + r["down"], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"total_up": total_up, "total_down": total_down,
|
|
|
|
|
|
"total_up_fmt": _fmt_bytes(total_up), "total_down_fmt": _fmt_bytes(total_down),
|
|
|
|
|
|
"total_fmt": _fmt_bytes(total_up + total_down),
|
|
|
|
|
|
"per_subscription": per_sub,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/subscriptions")
|
2026-09-11 22:20:54 +05:00
|
|
|
|
def admin_subscriptions(request: Request, limit: int = 200):
|
2026-09-10 17:45:43 +05:00
|
|
|
|
require_admin(request)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
subs = db.list_all_subscriptions(limit=limit)
|
|
|
|
|
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plans_by_code = settings.get_plans_by_code()
|
2026-09-10 17:45:43 +05:00
|
|
|
|
out = []
|
|
|
|
|
|
for s in subs:
|
2026-09-11 22:20:54 +05:00
|
|
|
|
node = nodes_by_code.get(s["node"])
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = plans_by_code.get(s["plan"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
out.append({
|
|
|
|
|
|
**s,
|
|
|
|
|
|
"node_label": node["label"] if node else s["node"],
|
|
|
|
|
|
"plan_label": plan["label"] if plan else s["plan"],
|
feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
|
|
|
|
"days_left": _days_left(s),
|
2026-09-10 17:45:43 +05:00
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/subscriptions/{uuid}/revoke")
|
|
|
|
|
|
def admin_revoke_subscription(uuid: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
sub = db.get_subscription(uuid)
|
2026-09-10 17:45:43 +05:00
|
|
|
|
if not sub:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
node = db.get_node(sub["node"])
|
|
|
|
|
|
if node:
|
|
|
|
|
|
xray_manager.remove_client_from_node(node, uuid)
|
|
|
|
|
|
db.revoke_subscription(uuid)
|
feat: outbound webhooks for revoke/hold/resume — only grant and payment fired before
Found while re-reading the subscription lifecycle routes: payment.paid
and subscription.granted_by_admin fire a webhook, but revoke (which has
existed the whole night) and the two new hold/resume routes did not.
Inconsistent for anyone actually wiring this into a CRM/support tool —
they'd see a subscription get granted but never find out it was later
paused, resumed, or cut off entirely, since only the "gains access"
side of the lifecycle was ever reported outward.
Three new events, same shape and delivery as the existing ones:
subscription.revoked, subscription.held, subscription.resumed. Added
right where the DB/xray state change already happens in each route, so
they're conditioned on the action actually succeeding (a hold attempt
on an already-held/expired subscription 400s before ever reaching the
webhooks.send call).
Verification: webhooks.py itself is unchanged — this only adds new call
sites with new event-name strings, so re-verified the exact thing the
original webhook feature proved: stood up a real local HTTP server,
fired all three new events through the actual webhooks.send(), and had
the receiver independently recompute the HMAC from its own copy of the
secret and compare against the X-Signature header it actually got, for
all three — not just trusting that the sender computed something.
Checked the JSON envelope and data payload match what each route sends
byte for byte. api.py itself still can't be imported locally, same wall
as always; the new lines were checked by reading the subscription-row
shape they pull from (tg_id/node/plan are all real columns already
confirmed present in every prior test this session) plus the standard
py_compile + pyflakes pass, clean across the whole repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:26:26 +05:00
|
|
|
|
webhooks.send("subscription.revoked", {
|
|
|
|
|
|
"tg_id": sub["tg_id"], "node": sub["node"], "plan": sub["plan"], "subscription_uuid": uuid,
|
|
|
|
|
|
})
|
2026-09-10 17:45:43 +05:00
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
|
|
|
|
@app.post("/admin/api/subscriptions/{uuid}/hold")
|
|
|
|
|
|
def admin_hold_subscription(uuid: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
sub = db.get_subscription(uuid)
|
|
|
|
|
|
if not sub:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
if not db.hold_subscription(uuid):
|
|
|
|
|
|
raise HTTPException(400, "подписка уже на паузе, истекла или отозвана")
|
|
|
|
|
|
node = db.get_node(sub["node"])
|
|
|
|
|
|
if node:
|
|
|
|
|
|
xray_manager.remove_client_from_node(node, uuid)
|
feat: outbound webhooks for revoke/hold/resume — only grant and payment fired before
Found while re-reading the subscription lifecycle routes: payment.paid
and subscription.granted_by_admin fire a webhook, but revoke (which has
existed the whole night) and the two new hold/resume routes did not.
Inconsistent for anyone actually wiring this into a CRM/support tool —
they'd see a subscription get granted but never find out it was later
paused, resumed, or cut off entirely, since only the "gains access"
side of the lifecycle was ever reported outward.
Three new events, same shape and delivery as the existing ones:
subscription.revoked, subscription.held, subscription.resumed. Added
right where the DB/xray state change already happens in each route, so
they're conditioned on the action actually succeeding (a hold attempt
on an already-held/expired subscription 400s before ever reaching the
webhooks.send call).
Verification: webhooks.py itself is unchanged — this only adds new call
sites with new event-name strings, so re-verified the exact thing the
original webhook feature proved: stood up a real local HTTP server,
fired all three new events through the actual webhooks.send(), and had
the receiver independently recompute the HMAC from its own copy of the
secret and compare against the X-Signature header it actually got, for
all three — not just trusting that the sender computed something.
Checked the JSON envelope and data payload match what each route sends
byte for byte. api.py itself still can't be imported locally, same wall
as always; the new lines were checked by reading the subscription-row
shape they pull from (tg_id/node/plan are all real columns already
confirmed present in every prior test this session) plus the standard
py_compile + pyflakes pass, clean across the whole repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:26:26 +05:00
|
|
|
|
webhooks.send("subscription.held", {
|
|
|
|
|
|
"tg_id": sub["tg_id"], "node": sub["node"], "plan": sub["plan"], "subscription_uuid": uuid,
|
|
|
|
|
|
})
|
feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/subscriptions/{uuid}/resume")
|
|
|
|
|
|
def admin_resume_subscription(uuid: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
resumed = db.resume_subscription(uuid)
|
|
|
|
|
|
if not resumed:
|
|
|
|
|
|
raise HTTPException(400, "подписка не на паузе")
|
|
|
|
|
|
node = db.get_node(resumed["node"])
|
|
|
|
|
|
if node:
|
|
|
|
|
|
xray_manager.add_client_to_node(node, uuid, email=uuid)
|
feat: outbound webhooks for revoke/hold/resume — only grant and payment fired before
Found while re-reading the subscription lifecycle routes: payment.paid
and subscription.granted_by_admin fire a webhook, but revoke (which has
existed the whole night) and the two new hold/resume routes did not.
Inconsistent for anyone actually wiring this into a CRM/support tool —
they'd see a subscription get granted but never find out it was later
paused, resumed, or cut off entirely, since only the "gains access"
side of the lifecycle was ever reported outward.
Three new events, same shape and delivery as the existing ones:
subscription.revoked, subscription.held, subscription.resumed. Added
right where the DB/xray state change already happens in each route, so
they're conditioned on the action actually succeeding (a hold attempt
on an already-held/expired subscription 400s before ever reaching the
webhooks.send call).
Verification: webhooks.py itself is unchanged — this only adds new call
sites with new event-name strings, so re-verified the exact thing the
original webhook feature proved: stood up a real local HTTP server,
fired all three new events through the actual webhooks.send(), and had
the receiver independently recompute the HMAC from its own copy of the
secret and compare against the X-Signature header it actually got, for
all three — not just trusting that the sender computed something.
Checked the JSON envelope and data payload match what each route sends
byte for byte. api.py itself still can't be imported locally, same wall
as always; the new lines were checked by reading the subscription-row
shape they pull from (tg_id/node/plan are all real columns already
confirmed present in every prior test this session) plus the standard
py_compile + pyflakes pass, clean across the whole repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:26:26 +05:00
|
|
|
|
webhooks.send("subscription.resumed", {
|
|
|
|
|
|
"tg_id": resumed["tg_id"], "node": resumed["node"], "plan": resumed["plan"],
|
|
|
|
|
|
"subscription_uuid": uuid, "expires_at": resumed["expires_at"],
|
|
|
|
|
|
})
|
feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
|
|
|
|
return {"ok": True, "expires_at": resumed["expires_at"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:14:16 +05:00
|
|
|
|
@app.post("/admin/api/subscriptions/{uuid}/reset-traffic")
|
|
|
|
|
|
def admin_reset_traffic(uuid: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
sub = db.get_subscription(uuid)
|
2026-09-10 22:14:16 +05:00
|
|
|
|
if not sub:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
node = db.get_node(sub["node"])
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
raise HTTPException(404, "node not found")
|
|
|
|
|
|
ok = xray_manager.reset_stats_for_node(node, uuid)
|
|
|
|
|
|
return {"ok": ok}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:17:16 +05:00
|
|
|
|
@app.get("/admin/api/users/{tg_id}")
|
|
|
|
|
|
def admin_user_card(tg_id: int, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
user = db.get_user(tg_id)
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
subs = db.list_subscriptions_for_user(tg_id)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plans_by_code = settings.get_plans_by_code()
|
2026-09-10 22:17:16 +05:00
|
|
|
|
out_subs = []
|
|
|
|
|
|
for s in subs:
|
2026-09-11 22:20:54 +05:00
|
|
|
|
node = nodes_by_code.get(s["node"])
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = plans_by_code.get(s["plan"])
|
2026-09-10 22:17:16 +05:00
|
|
|
|
out_subs.append({
|
|
|
|
|
|
**s,
|
|
|
|
|
|
"node_label": node["label"] if node else s["node"],
|
|
|
|
|
|
"plan_label": plan["label"] if plan else s["plan"],
|
feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
|
|
|
|
"days_left": _days_left(s),
|
2026-09-10 22:17:16 +05:00
|
|
|
|
})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"tg_id": user["tg_id"],
|
|
|
|
|
|
"username": user["username"],
|
|
|
|
|
|
"created_at": user["created_at"],
|
|
|
|
|
|
"token": user["token"],
|
|
|
|
|
|
"subscriptions": out_subs,
|
|
|
|
|
|
"devices": db.list_devices(tg_id),
|
|
|
|
|
|
"hwid_limit": user.get("hwid_limit"),
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
"hwid_fallback_limit": settings.get_hwid_settings()["fallback_limit"],
|
2026-09-10 22:17:16 +05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/users/{tg_id}/grant")
|
|
|
|
|
|
def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
node_code = body.get("node")
|
|
|
|
|
|
plan_code = body.get("plan")
|
|
|
|
|
|
node = db.get_node(node_code)
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = settings.get_plans_by_code().get(plan_code)
|
2026-09-10 22:17:16 +05:00
|
|
|
|
if not node or not plan:
|
|
|
|
|
|
raise HTTPException(400, "unknown node or plan")
|
|
|
|
|
|
db.get_or_create_user(tg_id, None)
|
|
|
|
|
|
sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin")
|
|
|
|
|
|
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
|
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
|
|
|
|
webhooks.send("subscription.granted_by_admin", {
|
|
|
|
|
|
"tg_id": tg_id, "node": node_code, "plan": plan_code,
|
|
|
|
|
|
"subscription_uuid": sub["uuid"], "expires_at": sub["expires_at"],
|
|
|
|
|
|
})
|
2026-09-10 22:17:16 +05:00
|
|
|
|
return sub
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:06:15 +05:00
|
|
|
|
@app.get("/admin/api/users/{tg_id}/devices")
|
|
|
|
|
|
def admin_list_devices(tg_id: int, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"devices": db.list_devices(tg_id),
|
|
|
|
|
|
"limit": db.get_or_create_user(tg_id, None).get("hwid_limit"),
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
"fallback_limit": settings.get_hwid_settings()["fallback_limit"],
|
2026-09-10 22:06:15 +05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.delete("/admin/api/users/{tg_id}/devices/{device_id}")
|
|
|
|
|
|
def admin_delete_device(tg_id: int, device_id: int, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
db.delete_device(device_id)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/users/{tg_id}/hwid-limit")
|
|
|
|
|
|
def admin_set_hwid_limit(tg_id: int, request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
limit = body.get("limit")
|
|
|
|
|
|
db.set_user_hwid_limit(tg_id, int(limit) if limit else None)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/gift-codes")
|
|
|
|
|
|
def admin_gift_codes(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
codes = db.list_gift_codes()
|
2026-09-11 22:20:54 +05:00
|
|
|
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plans_by_code = settings.get_plans_by_code()
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
_, bot_username = settings.bot_credentials()
|
2026-09-10 17:45:43 +05:00
|
|
|
|
out = []
|
|
|
|
|
|
for c in codes:
|
2026-09-11 22:20:54 +05:00
|
|
|
|
node = nodes_by_code.get(c["node"])
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = plans_by_code.get(c["plan"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
out.append({
|
|
|
|
|
|
**c,
|
|
|
|
|
|
"node_label": node["label"] if node else c["node"],
|
|
|
|
|
|
"plan_label": plan["label"] if plan else c["plan"],
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
"link": f"https://t.me/{bot_username}?start=gift_{c['code']}",
|
2026-09-10 17:45:43 +05:00
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/gift-codes")
|
|
|
|
|
|
def admin_create_gift_code(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
node, plan = body.get("node"), body.get("plan")
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
if node not in {n["code"] for n in db.list_nodes()} or plan not in settings.get_plans_by_code():
|
2026-09-10 17:45:43 +05:00
|
|
|
|
raise HTTPException(400, "invalid node/plan")
|
|
|
|
|
|
code = db.create_gift_code(node, plan, created_by=0)
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
_, bot_username = settings.bot_credentials()
|
|
|
|
|
|
return {"code": code, "link": f"https://t.me/{bot_username}?start=gift_{code}"}
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/plans")
|
|
|
|
|
|
def admin_plans(request: Request):
|
|
|
|
|
|
require_admin(request)
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
return settings.get_plans()
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/nodes")
|
|
|
|
|
|
def admin_nodes(request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
nodes = db.list_nodes()
|
|
|
|
|
|
for n in nodes:
|
|
|
|
|
|
n.pop("private_key", None)
|
|
|
|
|
|
n.pop("provision_token", None)
|
|
|
|
|
|
return nodes
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 10:55:15 +05:00
|
|
|
|
@app.post("/admin/api/nodes/reorder")
|
|
|
|
|
|
def admin_reorder_nodes(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
codes = body.get("codes")
|
|
|
|
|
|
if not isinstance(codes, list) or not codes:
|
|
|
|
|
|
raise HTTPException(400, "codes must be a non-empty list")
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.reorder_nodes(codes)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
@app.post("/admin/api/nodes")
|
|
|
|
|
|
def admin_create_node(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
node = db.create_node(
|
|
|
|
|
|
code=body["code"], label=body["label"], kind=body.get("kind", "external"),
|
|
|
|
|
|
address=body["address"], port=int(body.get("port", 443)),
|
|
|
|
|
|
public_key=body["public_key"], short_id=body["short_id"],
|
|
|
|
|
|
sni=body["sni"], flow=body.get("flow", "xtls-rprx-vision"),
|
|
|
|
|
|
shared_uuid=body.get("shared_uuid"),
|
|
|
|
|
|
)
|
|
|
|
|
|
return node
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.patch("/admin/api/nodes/{code}")
|
|
|
|
|
|
def admin_update_node(code: str, request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
2026-09-10 18:21:13 +05:00
|
|
|
|
editable = {"label", "enabled", "address", "port", "sni", "public_key", "short_id", "flow", "shared_uuid"}
|
|
|
|
|
|
if code == "de1":
|
|
|
|
|
|
editable = {"label"}
|
|
|
|
|
|
allowed = {k: v for k, v in body.items() if k in editable}
|
2026-09-10 17:45:43 +05:00
|
|
|
|
return db.update_node(code, **allowed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.delete("/admin/api/nodes/{code}")
|
|
|
|
|
|
def admin_delete_node(code: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.delete_node(code)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/nodes/{code}/check")
|
|
|
|
|
|
def admin_check_node(code: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
node = db.get_node(code)
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
alive = nodeprov.check_node_alive(node["address"], node["port"])
|
|
|
|
|
|
return {"alive": alive}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_uptime(seconds):
|
|
|
|
|
|
if seconds is None:
|
|
|
|
|
|
return "—"
|
|
|
|
|
|
days, rem = divmod(seconds, 86400)
|
|
|
|
|
|
hours, rem = divmod(rem, 3600)
|
|
|
|
|
|
minutes = rem // 60
|
|
|
|
|
|
if days:
|
|
|
|
|
|
return f"{days}д {hours}ч"
|
|
|
|
|
|
if hours:
|
|
|
|
|
|
return f"{hours}ч {minutes}м"
|
|
|
|
|
|
return f"{minutes}м"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/nodes/{code}/metrics")
|
|
|
|
|
|
def admin_node_metrics(code: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
node = db.get_node(code)
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
status = xray_manager.local_node_status() if node["kind"] == "local" else nodeprov.remote_node_status(node)
|
|
|
|
|
|
if status.get("ok") and status.get("mem_total_mb"):
|
|
|
|
|
|
status["mem_fmt"] = f"{status['mem_used_mb']} / {status['mem_total_mb']} MB"
|
|
|
|
|
|
else:
|
|
|
|
|
|
status["mem_fmt"] = "—"
|
|
|
|
|
|
status["uptime_fmt"] = _fmt_uptime(status.get("uptime_s"))
|
|
|
|
|
|
return status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/admin/api/nodes/provision-guide")
|
|
|
|
|
|
def admin_provision_guide(request: Request, body: dict = Body(...)):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
label = body["label"]
|
|
|
|
|
|
address = body["address"]
|
|
|
|
|
|
port = int(body.get("port", 443))
|
|
|
|
|
|
sni = body.get("sni") or "www.wildberries.ru"
|
|
|
|
|
|
include_ws = bool(body.get("include_ws"))
|
|
|
|
|
|
include_hysteria2 = bool(body.get("include_hysteria2"))
|
|
|
|
|
|
|
|
|
|
|
|
private_key, public_key = nodeprov.generate_reality_keys()
|
|
|
|
|
|
transports = nodeprov.build_transports(address, port, sni, public_key, include_ws=include_ws)
|
|
|
|
|
|
short_id = transports[0]["short_id"]
|
|
|
|
|
|
|
|
|
|
|
|
hysteria_port = hysteria_password = hysteria_obfs_password = None
|
|
|
|
|
|
if include_hysteria2:
|
|
|
|
|
|
hysteria_port = int(body.get("hysteria_port", 443))
|
|
|
|
|
|
hysteria_password, hysteria_obfs_password = nodeprov.generate_hysteria_credentials()
|
|
|
|
|
|
|
|
|
|
|
|
node, token = db.create_pending_node(
|
|
|
|
|
|
label, address, port, sni, private_key, public_key, short_id,
|
|
|
|
|
|
transports_json=json.dumps(transports),
|
|
|
|
|
|
hysteria_port=hysteria_port, hysteria_password=hysteria_password,
|
|
|
|
|
|
hysteria_obfs_password=hysteria_obfs_password,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {"code": node["code"], "command": nodeprov.one_command(token)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/admin/api/nodes/{code}/status")
|
|
|
|
|
|
def admin_node_status(code: str, request: Request):
|
|
|
|
|
|
require_admin(request)
|
|
|
|
|
|
node = db.get_node(code)
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
return {"status": node["status"], "enabled": bool(node["enabled"])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ADMIN_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "admin.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 23:34:23 +05:00
|
|
|
|
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
|
|
|
|
|
|
|
|
|
|
|
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
2026-09-10 17:45:43 +05:00
|
|
|
|
def root(request: Request):
|
feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have
Pulled a fresh copy of docs.rw's own Remnawave-vs-Marzban comparison
table (not working from memory of an earlier read) to check what's
still genuinely different after tonight's run of fixes — most rows
already match or beat both panels (multi-admin, 2FA, HWID limits,
backup/restore, host sorting, config validation, node autonomy, on-hold
status as of a few commits ago). One concrete, bounded, unclaimed row:
"Security measures in documentation" lists CF zero trust / custom path
/ Telegram OAuth / 2FA for Remnawave, nothing for Marzban. We already
had 2FA and rate-limiting; custom path was the missing, actually
implementable piece — everything else in that row is deployment
guidance, not panel code.
New ADMIN_PATH env var (config.py, defaults to "admin" — every existing
install keeps working exactly as before with zero action needed). The
page-serving route moves to whatever path is configured; root() on
PANEL_DOMAIN only falls through to serving admin.html when ADMIN_PATH
is still the default, otherwise it shows the same branded landing page
every other domain gets — so a scanner or a human guessing "/admin"
finds nothing once this is set, not even a redirect that confirms
something lives there.
Deliberately scoped to ONLY the page route. /admin/api/* stays fixed —
it's already behind real cookie+session auth (verified this while
auditing: every mutating admin route either calls require_admin() or
the equivalent _require_current_admin(), checked programmatically via
ast rather than trusting my memory of having added the check everywhere
— found nothing actually missing, which is itself worth knowing, not
just assumed). Moving the API namespace too would be a much bigger,
riskier rewrite of every @app decorator in the file for no real security
gain over what auth already provides.
Deliberately NOT exposed in the Settings UI, unlike almost everything
else made live-editable tonight. This one genuinely needs a process
restart to take effect (FastAPI resolves routes at import time, not
per-request), and a typo saved through the UI followed by a restart
is a real self-lockout risk with no web-based way back — same tier as
PANEL_DOMAIN/SUB_DOMAIN, which are also .env-only for the same reason.
.env + SSH is the correct blast radius for a setting that can lock you
out.
Verification: config.py's normalization (strip slashes, empty/lone-
slash/repeated-slash input all falling back to "admin" rather than
accidentally producing a route at bare "/") tested directly — 8 cases.
AST-extracted the updated root() out of api.py (still can't import the
module locally) and exercised its actual branching with a mocked
FileResponse/legal/request — confirmed the default case is byte-for-
byte the old behavior and the custom-path case stops serving admin.html
on PANEL_DOMAIN's root. Added a dedicated CI step that does what only a
real FastAPI import can prove: with ADMIN_PATH set, /xyz123secret is a
registered route, plain /admin is NOT (not just supplemented — actually
gone), and /admin/api/login is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:58:45 +05:00
|
|
|
|
host = request.headers.get("host", "").split(":")[0]
|
|
|
|
|
|
if host == PANEL_DOMAIN and ADMIN_PATH == "admin":
|
2026-09-10 23:34:23 +05:00
|
|
|
|
return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
return legal.render_site_page("index.html")
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
|
|
|
|
|
|
feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have
Pulled a fresh copy of docs.rw's own Remnawave-vs-Marzban comparison
table (not working from memory of an earlier read) to check what's
still genuinely different after tonight's run of fixes — most rows
already match or beat both panels (multi-admin, 2FA, HWID limits,
backup/restore, host sorting, config validation, node autonomy, on-hold
status as of a few commits ago). One concrete, bounded, unclaimed row:
"Security measures in documentation" lists CF zero trust / custom path
/ Telegram OAuth / 2FA for Remnawave, nothing for Marzban. We already
had 2FA and rate-limiting; custom path was the missing, actually
implementable piece — everything else in that row is deployment
guidance, not panel code.
New ADMIN_PATH env var (config.py, defaults to "admin" — every existing
install keeps working exactly as before with zero action needed). The
page-serving route moves to whatever path is configured; root() on
PANEL_DOMAIN only falls through to serving admin.html when ADMIN_PATH
is still the default, otherwise it shows the same branded landing page
every other domain gets — so a scanner or a human guessing "/admin"
finds nothing once this is set, not even a redirect that confirms
something lives there.
Deliberately scoped to ONLY the page route. /admin/api/* stays fixed —
it's already behind real cookie+session auth (verified this while
auditing: every mutating admin route either calls require_admin() or
the equivalent _require_current_admin(), checked programmatically via
ast rather than trusting my memory of having added the check everywhere
— found nothing actually missing, which is itself worth knowing, not
just assumed). Moving the API namespace too would be a much bigger,
riskier rewrite of every @app decorator in the file for no real security
gain over what auth already provides.
Deliberately NOT exposed in the Settings UI, unlike almost everything
else made live-editable tonight. This one genuinely needs a process
restart to take effect (FastAPI resolves routes at import time, not
per-request), and a typo saved through the UI followed by a restart
is a real self-lockout risk with no web-based way back — same tier as
PANEL_DOMAIN/SUB_DOMAIN, which are also .env-only for the same reason.
.env + SSH is the correct blast radius for a setting that can lock you
out.
Verification: config.py's normalization (strip slashes, empty/lone-
slash/repeated-slash input all falling back to "admin" rather than
accidentally producing a route at bare "/") tested directly — 8 cases.
AST-extracted the updated root() out of api.py (still can't import the
module locally) and exercised its actual branching with a mocked
FileResponse/legal/request — confirmed the default case is byte-for-
byte the old behavior and the custom-path case stops serving admin.html
on PANEL_DOMAIN's root. Added a dedicated CI step that does what only a
real FastAPI import can prove: with ADMIN_PATH set, /xyz123secret is a
registered route, plain /admin is NOT (not just supplemented — actually
gone), and /admin/api/login is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:58:45 +05:00
|
|
|
|
@app.get(f"/{ADMIN_PATH}")
|
2026-09-10 17:45:43 +05:00
|
|
|
|
def admin_page():
|
2026-09-10 23:34:23 +05:00
|
|
|
|
return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
|
|
|
|
|
|
|
|
|
|
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
@app.get("/cabinet.html", response_class=HTMLResponse)
|
|
|
|
|
|
def cabinet_page():
|
|
|
|
|
|
return legal.render_site_page("cabinet.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
|
|
|
|
@app.get("/offer", response_class=HTMLResponse)
|
|
|
|
|
|
def offer_page():
|
|
|
|
|
|
return legal.render("offer.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/privacy", response_class=HTMLResponse)
|
|
|
|
|
|
def privacy_page():
|
|
|
|
|
|
return legal.render("privacy.html")
|
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>
2026-09-13 23:52:54 +05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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()}
|