diff --git a/.env.example b/.env.example index 8237c62..2bb624d 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,8 @@ DE1_ADDRESS=de1.example.com # Payments — off by default, bot keeps handing out free subscriptions on button press. # Flip to true only once at least one provider below is configured and its webhook is live. +# All of this (toggle, prices, provider keys) is also editable live from the admin panel +# (Платежи tab) after first boot — no need to hand-edit this file or restart afterwards. PAYMENTS_ENABLED=false # Prices in RUB per plan (whole numbers). Only used when PAYMENTS_ENABLED=true. @@ -58,6 +60,6 @@ PLATEGA_SECRET= # Device limit (HWID) — off by default. Requires the VPN client app to send an # x-hwid header on subscription fetch (Happ/v2rayTun-class apps do this); clients # that don't send it get refused once enabled, so only flip this on if your users' -# apps actually support it. +# apps actually support it. Also editable live from Настройки in the admin panel. HWID_LIMIT_ENABLED=false HWID_FALLBACK_LIMIT=3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74f52f..e396f5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,3 +182,61 @@ jobs: print("all v1.1.0 feature smoke tests passed") PYEOF + + - name: Smoke test live settings (.env-backed plans/toggles/HWID/credentials, no restart) + env: + BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + BOT_USERNAME: "x" + ADMIN_IDS: "1" + ADMIN_PANEL_PASSWORD: "ci-test-password-not-real" + PANEL_DOMAIN: "panel.test" + SUB_DOMAIN: "sub.test" + SITE_DOMAIN: "test" + XRAY_PUBLIC_KEY: "x" + XRAY_SHORT_ID_TCP: "x" + XRAY_SHORT_ID_GRPC: "x" + XRAY_SHORT_ID_XHTTP: "x" + run: | + python - << 'PYEOF' + import hashlib + import hmac + + with open(".env", "a", encoding="utf-8") as f: + f.write("PLATEGA_SECRET=old_secret\n") + f.write("PLATEGA_ENABLED=true\n") + f.write("PLATEGA_MERCHANT_ID=m1\n") + + import legal + import settings + import payments + + plans = settings.get_plans_by_code() + assert plans["1m"]["price"] > 0, "default price should come from config before any .env override" + + settings.set_plan_prices({"1m": 4242}) + assert settings.get_plans_by_code()["1m"]["price"] == 4242, "price edit should apply live, no reimport" + assert settings.get_plans_by_code()["7d"]["price"] != 4242, "unrelated plan must stay untouched" + + assert settings.get_hwid_settings()["enabled"] is False + legal.update_env_var("HWID_LIMIT_ENABLED", "true") + legal.update_env_var("HWID_FALLBACK_LIMIT", "9") + hwid = settings.get_hwid_settings() + assert hwid["enabled"] is True and hwid["fallback_limit"] == 9, "HWID settings should apply live" + + body = b'{"transactionId":"t1","status":"CONFIRMED"}' + sig_old = hmac.new(b"old_secret", body, hashlib.sha256).hexdigest() + assert payments.verify_platega_signature(body, sig_old), "signature must verify against the current secret" + + legal.update_env_var("PLATEGA_SECRET", "rotated_secret") + assert not payments.verify_platega_signature(body, sig_old), "OLD signature must be rejected right after rotation, same process, no restart" + sig_new = hmac.new(b"rotated_secret", body, hashlib.sha256).hexdigest() + assert payments.verify_platega_signature(body, sig_new), "NEW signature must verify immediately after rotation, same process, no restart" + + for _ in range(5): + legal.update_env_var("HWID_FALLBACK_LIMIT", "9") + with open(".env", encoding="utf-8") as f: + lines = [l for l in f.readlines() if l.startswith("HWID_FALLBACK_LIMIT=")] + assert len(lines) == 1, "repeated writes to the same key must not duplicate .env lines" + + print("live settings: prices/HWID/credential-rotation all apply with zero reimport OK") + PYEOF diff --git a/admin.html b/admin.html index 12c224d..1e175c7 100644 --- a/admin.html +++ b/admin.html @@ -539,7 +539,7 @@
-

Панель сама постучится в ЮKassa (/v3/me) и сохранит ключи только если они рабочие. После сохранения включаются приём оплаты и вебхуки; бот перезапустится сам, для самой панели (обработка вебхуков) один раз выполни на сервере mbs restart.

+

Панель сама постучится в ЮKassa (/v3/me) и сохранит ключи только если они рабочие. После сохранения сразу включаются приём оплаты и приём вебхуков — без рестарта; бот на всякий случай перезапускается сам, чтобы кнопки оплаты в Telegram тоже обновились немедленно.

Platega — ключи API

@@ -549,10 +549,21 @@
-

У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Тот же рестарт нужен, что и для ЮKassa.

+

У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Применяется сразу, без рестарта.

+
+

Тарифы

+

Цены по срокам подписки и общий приём оплаты — меняются здесь, применяются сразу, рестарт не нужен.

+ +
+
+ +
+
+
+

История

@@ -595,6 +606,12 @@

Вкладка Платежи → «Настройка приёма платежей» собирает публичную оферту и политику конфиденциальности (/offer, /privacy) из введённых реквизитов — ЮKassa их спросит при регистрации магазина. Дата вступления в силу проставляется один раз, правки реквизитов её не двигают.

Ключи ЮKassa проверяются вживую через их /v3/me перед сохранением; у Platega такого эндпоинта нет, ключи сохраняются без проверки. Оба провайдера включаются независимо.

Исходящие вебхуки (Настройки → Webhook на события) — панель стучится на указанный URL при оплате (payment.paid) и ручной выдаче подписки админом (subscription.granted_by_admin), тело подписано X-Signature (HMAC-SHA256). Секрет выдаётся один раз и не меняется при правке URL — для интеграций со своими системами, без опроса API.

+

Вкладка Платежи → «Тарифы» — цены по срокам и общий рубильник приёма оплаты. Как и реквизиты с ключами провайдеров, это читается панелью напрямую из .env при каждом запросе — правки в UI применяются мгновенно везде (бот, API, проверка вебхуков), рестарт панели нигде не требуется.

+ + +
+

Лимит устройств (HWID)

+

Настройки → «Лимит устройств» — глобальный рубильник и лимит по умолчанию (как у Remnawave: клиент шлёт заголовок x-hwid при запросе конфига, панель запоминает первые N уникальных устройств на юзера и отказывает новым сверх лимита). У конкретного юзера лимит можно переопределить отдельно — в его карточке (Подписки → кнопка «Карточка» → таб «Устройства»), это имеет приоритет над глобальным значением по умолчанию.

@@ -684,6 +701,18 @@

+ +
+

Лимит устройств (HWID)

+

Ограничивает число разных устройств на одну подписку — как у Remnawave. У конкретного юзера лимит можно переопределить в его карточке, это значение — только дефолт для тех, у кого свой не задан.

+ +
+
+
+
+

Применяется сразу, без рестарта панели.

+
+
@@ -768,7 +797,7 @@ function showView(name) { if (name === "nodes") loadNodes(); if (name === "traffic") loadTraffic(); if (name === "payments") { loadPayments(); loadPaymentsSettings(); } - if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); } + if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); loadHwidSettings(); } } const COUNTRIES = [ @@ -1038,10 +1067,11 @@ async function checkPayment(id, btn) { } async function loadPaymentsSettings() { - const [legalRes, ykRes, pgRes] = await Promise.all([ + const [legalRes, ykRes, pgRes, planRes] = await Promise.all([ api("/admin/api/payments/legal-settings"), api("/admin/api/payments/yookassa-settings"), api("/admin/api/payments/platega-settings"), + api("/admin/api/payments/plan-settings"), ]); document.getElementById("legal-name").value = legalRes.LEGAL_NAME || ""; document.getElementById("legal-inn").value = legalRes.LEGAL_INN || ""; @@ -1064,6 +1094,11 @@ async function loadPaymentsSettings() { } else { pgStatus.innerHTML = 'не настроена'; } + + document.getElementById("plan-payments-enabled").checked = !!planRes.payments_enabled; + document.getElementById("plan-price-inputs").innerHTML = planRes.plans.map((p) => ` +
+ `).join(""); } async function saveLegalSettings() { @@ -1090,8 +1125,8 @@ async function saveYookassaSettings() { if (!shop_id || !secret_key) return; result.innerHTML = '

Проверяю ключи у ЮKassa…

'; try { - const res = await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) }); - result.innerHTML = `

Ключи рабочие, сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере mbs restart.

`; + await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) }); + result.innerHTML = '

Ключи рабочие, сохранено — бот и приём вебхуков подхватывают их сразу, рестарт не нужен.

'; document.getElementById("yk-secret-key").value = ""; loadPaymentsSettings(); } catch (e) { @@ -1105,8 +1140,8 @@ async function savePlategaSettings() { const result = document.getElementById("platega-result"); if (!merchant_id || !secret) return; try { - const res = await api("/admin/api/payments/platega-settings", { method: "POST", body: JSON.stringify({ merchant_id, secret }) }); - result.innerHTML = `

Сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере mbs restart.

`; + await api("/admin/api/payments/platega-settings", { method: "POST", body: JSON.stringify({ merchant_id, secret }) }); + result.innerHTML = '

Сохранено — бот и приём вебхуков подхватывают ключи сразу, рестарт не нужен.

'; document.getElementById("pg-secret").value = ""; loadPaymentsSettings(); } catch (e) { @@ -1114,6 +1149,47 @@ async function savePlategaSettings() { } } +async function savePlanSettings() { + const result = document.getElementById("plan-settings-result"); + const prices = {}; + document.querySelectorAll(".plan-price-input").forEach((el) => { + prices[el.dataset.planCode] = el.value.trim(); + }); + const body = { + payments_enabled: document.getElementById("plan-payments-enabled").checked, + prices, + }; + try { + await api("/admin/api/payments/plan-settings", { method: "POST", body: JSON.stringify(body) }); + result.innerHTML = '

Сохранено — применилось сразу, без рестарта

'; + loadPaymentsSettings(); + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } +} + +async function loadHwidSettings() { + const res = await api("/admin/api/hwid-settings"); + document.getElementById("hwid-enabled").checked = !!res.enabled; + document.getElementById("hwid-fallback-limit").value = res.fallback_limit; + document.getElementById("hwid-result").innerHTML = ""; +} + +async function saveHwidSettings() { + const result = document.getElementById("hwid-result"); + const body = { + enabled: document.getElementById("hwid-enabled").checked, + fallback_limit: document.getElementById("hwid-fallback-limit").value.trim(), + }; + try { + await api("/admin/api/hwid-settings", { method: "POST", body: JSON.stringify(body) }); + result.innerHTML = '

Сохранено — применилось сразу, без рестарта

'; + loadHwidSettings(); + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } +} + async function loadBotSettings() { const data = await api("/admin/api/settings/bot"); document.getElementById("settings-bot-username").textContent = "@" + data.username; diff --git a/api.py b/api.py index a538c89..477f6bb 100644 --- a/api.py +++ b/api.py @@ -17,14 +17,11 @@ import legal import links import nodeprov import payments +import settings import totp import webhooks import xray_manager -from config import ( - PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, - BOT_USERNAME, BOT_TOKEN, BASE_DIR, - HWID_LIMIT_ENABLED, HWID_FALLBACK_LIMIT, -) +from config import SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, BOT_USERNAME, BOT_TOKEN, BASE_DIR HWID_RE = re.compile(r"^[a-zA-Z0-9=-]{10,64}$") ENV_PATH = os.path.join(BASE_DIR, ".env") @@ -33,20 +30,7 @@ db.init_db() def _update_env_var(key: str, value: str): - lines = [] - if os.path.exists(ENV_PATH): - with open(ENV_PATH, encoding="utf-8") as f: - lines = f.readlines() - found = False - for i, line in enumerate(lines): - if line.strip().startswith(f"{key}="): - lines[i] = f"{key}={value}\n" - found = True - break - if not found: - lines.append(f"{key}={value}\n") - with open(ENV_PATH, "w", encoding="utf-8") as f: - f.writelines(lines) + legal.update_env_var(key, value) app = FastAPI(title="mbs-api") @@ -243,11 +227,12 @@ def get_subscription(token: str, request: Request): sub_url = f"https://{SUB_DOMAIN}/sub/{token}" return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url)) - if HWID_LIMIT_ENABLED: + hwid_cfg = settings.get_hwid_settings() + if hwid_cfg["enabled"]: hwid = request.headers.get("x-hwid", "") if not HWID_RE.match(hwid): raise HTTPException(404, "hwid required") - limit = user["hwid_limit"] if user["hwid_limit"] is not None else HWID_FALLBACK_LIMIT + limit = user["hwid_limit"] if user["hwid_limit"] is not None else hwid_cfg["fallback_limit"] _, allowed = db.add_device_if_under_limit( user["tg_id"], hwid, limit, request.headers.get("x-device-os"), @@ -267,9 +252,10 @@ def cabinet(token: str): if not user: raise HTTPException(404, "not found") subs = db.list_active_subscriptions(tg_id=user["tg_id"]) + plans_by_code = settings.get_plans_by_code() out = [] for s in subs: - plan = PLANS_BY_CODE.get(s["plan"]) + plan = plans_by_code.get(s["plan"]) out.append({ "node": s["node"], "plan": s["plan"], @@ -313,7 +299,7 @@ def _grant_paid_subscription(payment_id: str): payment = db.get_payment(payment_id) if not payment or payment["status"] == "paid": return - plan = PLANS_BY_CODE.get(payment["plan"]) + plan = settings.get_plans_by_code().get(payment["plan"]) node = db.get_node(payment["node"]) if not plan or not node: return @@ -361,10 +347,11 @@ def _check_and_reconcile_payment(payment: dict) -> str: def admin_list_payments(request: Request): require_admin(request) nodes_by_code = {n["code"]: n for n in db.list_nodes()} + plans_by_code = settings.get_plans_by_code() out = [] for p in db.list_payments(): node = nodes_by_code.get(p["node"]) - plan = PLANS_BY_CODE.get(p["plan"]) + plan = plans_by_code.get(p["plan"]) out.append({ **p, "node_label": node["label"] if node else p["node"], @@ -406,10 +393,11 @@ def admin_set_legal_settings(request: Request, body: dict = Body(...)): @app.get("/admin/api/payments/yookassa-settings") def admin_get_yookassa_settings(request: Request): require_admin(request) + shop_id, secret_key = settings.yookassa_credentials() return { - "enabled": payments.YOOKASSA_ENABLED, - "shop_id": legal.read_env_var("YOOKASSA_SHOP_ID", ""), - "has_secret": bool(legal.read_env_var("YOOKASSA_SECRET_KEY", "")), + "enabled": settings.get_payment_settings()["yookassa_enabled"], + "shop_id": shop_id, + "has_secret": bool(secret_key), } @@ -440,10 +428,11 @@ def admin_set_yookassa_settings(request: Request, body: dict = Body(...)): @app.get("/admin/api/payments/platega-settings") def admin_get_platega_settings(request: Request): require_admin(request) + merchant_id, secret = settings.platega_credentials() return { - "enabled": payments.PLATEGA_ENABLED, - "merchant_id": legal.read_env_var("PLATEGA_MERCHANT_ID", ""), - "has_secret": bool(legal.read_env_var("PLATEGA_SECRET", "")), + "enabled": settings.get_payment_settings()["platega_enabled"], + "merchant_id": merchant_id, + "has_secret": bool(secret), } @@ -467,6 +456,62 @@ def admin_set_platega_settings(request: Request, body: dict = Body(...)): return {"ok": True, "restarted_bot": restarted} +@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() + + @app.get("/admin/api/webhook-settings") def admin_get_webhook_settings(request: Request): require_admin(request) @@ -813,10 +858,11 @@ def admin_subscriptions(request: Request, limit: int = 200): require_admin(request) subs = db.list_all_subscriptions(limit=limit) nodes_by_code = {n["code"]: n for n in db.list_nodes()} + plans_by_code = settings.get_plans_by_code() out = [] for s in subs: node = nodes_by_code.get(s["node"]) - plan = PLANS_BY_CODE.get(s["plan"]) + plan = plans_by_code.get(s["plan"]) out.append({ **s, "node_label": node["label"] if node else s["node"], @@ -860,10 +906,11 @@ def admin_user_card(tg_id: int, request: Request): raise HTTPException(404, "not found") subs = db.list_subscriptions_for_user(tg_id) nodes_by_code = {n["code"]: n for n in db.list_nodes()} + plans_by_code = settings.get_plans_by_code() out_subs = [] for s in subs: node = nodes_by_code.get(s["node"]) - plan = PLANS_BY_CODE.get(s["plan"]) + plan = plans_by_code.get(s["plan"]) out_subs.append({ **s, "node_label": node["label"] if node else s["node"], @@ -878,7 +925,7 @@ def admin_user_card(tg_id: int, request: Request): "subscriptions": out_subs, "devices": db.list_devices(tg_id), "hwid_limit": user.get("hwid_limit"), - "hwid_fallback_limit": HWID_FALLBACK_LIMIT, + "hwid_fallback_limit": settings.get_hwid_settings()["fallback_limit"], } @@ -888,7 +935,7 @@ def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(... node_code = body.get("node") plan_code = body.get("plan") node = db.get_node(node_code) - plan = PLANS_BY_CODE.get(plan_code) + plan = settings.get_plans_by_code().get(plan_code) if not node or not plan: raise HTTPException(400, "unknown node or plan") db.get_or_create_user(tg_id, None) @@ -907,7 +954,7 @@ def admin_list_devices(tg_id: int, request: Request): return { "devices": db.list_devices(tg_id), "limit": db.get_or_create_user(tg_id, None).get("hwid_limit"), - "fallback_limit": HWID_FALLBACK_LIMIT, + "fallback_limit": settings.get_hwid_settings()["fallback_limit"], } @@ -932,10 +979,11 @@ def admin_gift_codes(request: Request): require_admin(request) codes = db.list_gift_codes() nodes_by_code = {n["code"]: n for n in db.list_nodes()} + plans_by_code = settings.get_plans_by_code() out = [] for c in codes: node = nodes_by_code.get(c["node"]) - plan = PLANS_BY_CODE.get(c["plan"]) + plan = plans_by_code.get(c["plan"]) out.append({ **c, "node_label": node["label"] if node else c["node"], @@ -949,7 +997,7 @@ def admin_gift_codes(request: Request): def admin_create_gift_code(request: Request, body: dict = Body(...)): require_admin(request) node, plan = body.get("node"), body.get("plan") - if node not in {n["code"] for n in db.list_nodes()} or plan not in PLANS_BY_CODE: + if node not in {n["code"] for n in db.list_nodes()} or plan not in settings.get_plans_by_code(): raise HTTPException(400, "invalid node/plan") code = db.create_gift_code(node, plan, created_by=0) return {"code": code, "link": f"https://t.me/{BOT_USERNAME}?start=gift_{code}"} @@ -958,7 +1006,7 @@ def admin_create_gift_code(request: Request, body: dict = Body(...)): @app.get("/admin/api/plans") def admin_plans(request: Request): require_admin(request) - return PLANS + return settings.get_plans() diff --git a/bot.py b/bot.py index 86efcd4..0c9d09c 100644 --- a/bot.py +++ b/bot.py @@ -8,11 +8,11 @@ from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode import db -import links import payments +import settings import webhooks import xray_manager -from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED +from config import BOT_TOKEN, ADMIN_IDS, SUB_DOMAIN, SITE_DOMAIN logging.basicConfig(level=logging.INFO) log = logging.getLogger("mbs-bot") @@ -49,9 +49,10 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup: def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup: + payments_enabled = settings.get_payment_settings()["payments_enabled"] rows = [] - for p in PLANS: - label = f"{p['label']} — {p['price']} ₽" if PAYMENTS_ENABLED and p["price"] > 0 else p["label"] + for p in settings.get_plans(): + label = f"{p['label']} — {p['price']} ₽" if payments_enabled and p["price"] > 0 else p["label"] rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")]) rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")]) return InlineKeyboardMarkup(inline_keyboard=rows) @@ -97,7 +98,7 @@ async def start_deeplink(message: Message, command: CommandObject): if err == "already_used": await message.answer("Этот код уже был использован.") return await send_main_menu(message) - plan = PLANS_BY_CODE.get(gift["plan"]) + plan = settings.get_plans_by_code().get(gift["plan"]) gift_node = db.get_node(gift["node"]) if not plan or not gift_node: await message.answer("Этот подарок больше недоступен.") @@ -162,10 +163,10 @@ def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup: @dp.callback_query(F.data.startswith("plan:")) async def cb_plan(cb: CallbackQuery): _, node_code, plan_code = cb.data.split(":") - plan = PLANS_BY_CODE[plan_code] + plan = settings.get_plans_by_code()[plan_code] db.get_or_create_user(cb.from_user.id, cb.from_user.username) - if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers(): + if settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and payments.available_providers(): await cb.message.edit_text( f"{plan['label']} — {plan['price']} ₽\n\nВыбери способ оплаты:", reply_markup=providers_kb(node_code, plan_code), @@ -194,7 +195,7 @@ async def cb_plan(cb: CallbackQuery): @dp.callback_query(F.data.startswith("pay:")) async def cb_pay(cb: CallbackQuery): _, provider, node_code, plan_code = cb.data.split(":") - plan = PLANS_BY_CODE[plan_code] + plan = settings.get_plans_by_code()[plan_code] node_row = db.get_node(node_code) payment_id = payments.new_payment_id() db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"]) @@ -228,8 +229,9 @@ async def cb_mysub(cb: CallbackQuery): return await cb.answer() lines = ["Твои подписки\n"] nodes_by_code = {n["code"]: n for n in db.list_nodes()} + plans_by_code = settings.get_plans_by_code() for s in subs: - plan = PLANS_BY_CODE.get(s["plan"], {}).get("label", s["plan"]) + plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"]) node_info = nodes_by_code.get(s["node"]) node = node_info["label"] if node_info else s["node"] lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}") @@ -284,7 +286,7 @@ async def cb_admin_giftmake(cb: CallbackQuery): me = await bot.get_me() _bot_username = me.username link = f"https://t.me/{_bot_username}?start=gift_{code}" - plan = PLANS_BY_CODE[plan_code] + plan = settings.get_plans_by_code()[plan_code] kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]]) await cb.message.edit_text( f"Гифт-ссылка готова ({db.get_node(node_code)['label']}, {plan['label']}):\n\n" @@ -325,9 +327,10 @@ async def cb_admin_sync(cb: CallbackQuery): async def reconcile_pending_payments(): - if not PAYMENTS_ENABLED: + if not settings.get_payment_settings()["payments_enabled"]: return nodes_by_code = {n["code"]: n for n in db.list_nodes()} + plans_by_code = settings.get_plans_by_code() for payment in db.list_payments(): if payment["status"] != "pending" or not payment.get("external_id"): continue @@ -336,7 +339,7 @@ async def reconcile_pending_payments(): except Exception: continue if status in payments.PAID_STATUSES: - plan = PLANS_BY_CODE.get(payment["plan"]) + plan = plans_by_code.get(payment["plan"]) node_row = nodes_by_code.get(payment["node"]) if not plan or not node_row: continue diff --git a/legal.py b/legal.py index 36c3039..c882724 100644 --- a/legal.py +++ b/legal.py @@ -9,19 +9,47 @@ SITE_DIR = os.path.join(BASE_DIR, "site") FIELD_KEYS = ["LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL", "OFFER_EFFECTIVE_DATE"] -def read_env_var(key: str, default: str = "") -> str: +def read_env_vars(keys: list) -> dict: + result = {key: None for key in keys} if not os.path.exists(ENV_PATH): - return default + return result + wanted = set(keys) with open(ENV_PATH, encoding="utf-8") as f: for line in f: line = line.strip() - if line.startswith(f"{key}="): - return line[len(key) + 1:] - return default + if "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + if key in wanted and result[key] is None: + result[key] = value + return result + + +def read_env_var(key: str, default: str = "") -> str: + value = read_env_vars([key])[key] + return default if value is None else value + + +def update_env_var(key: str, value: str): + lines = [] + if os.path.exists(ENV_PATH): + with open(ENV_PATH, encoding="utf-8") as f: + lines = f.readlines() + found = False + for i, line in enumerate(lines): + if line.strip().startswith(f"{key}="): + lines[i] = f"{key}={value}\n" + found = True + break + if not found: + lines.append(f"{key}={value}\n") + with open(ENV_PATH, "w", encoding="utf-8") as f: + f.writelines(lines) def get_settings() -> dict: - return {key: read_env_var(key) for key in FIELD_KEYS} + raw = read_env_vars(FIELD_KEYS) + return {key: (raw[key] or "") for key in FIELD_KEYS} def _fallback(label: str) -> str: diff --git a/payments.py b/payments.py index 441175d..22f0d78 100644 --- a/payments.py +++ b/payments.py @@ -5,20 +5,18 @@ import json import secrets import urllib.request -from config import ( - PANEL_DOMAIN, - YOOKASSA_ENABLED, YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, - PLATEGA_ENABLED, PLATEGA_MERCHANT_ID, PLATEGA_SECRET, -) +from config import PANEL_DOMAIN +import settings PROVIDER_NAMES = {"yookassa": "ЮKassa", "platega": "Platega"} def available_providers() -> list[str]: + enabled = settings.get_payment_settings() providers = [] - if YOOKASSA_ENABLED: + if enabled["yookassa_enabled"]: providers.append("yookassa") - if PLATEGA_ENABLED: + if enabled["platega_enabled"]: providers.append("platega") return providers @@ -41,7 +39,8 @@ def _get_json(url: str, headers: dict, timeout: int = 15) -> dict: def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) -> str: - auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode() + shop_id, secret_key = settings.yookassa_credentials() + auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode() data = _post_json( "https://api.yookassa.ru/v3/payments", { @@ -72,7 +71,8 @@ def verify_yookassa_notification(body: dict) -> bool: def check_yookassa_payment(external_id: str) -> str: - auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode() + shop_id, secret_key = settings.yookassa_credentials() + auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode() data = _get_json( f"https://api.yookassa.ru/v3/payments/{external_id}", {"Authorization": f"Basic {auth}"}, @@ -81,6 +81,7 @@ def check_yookassa_payment(external_id: str) -> str: def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str: + merchant_id, secret = settings.platega_credentials() data = _post_json( "https://app.platega.io/transaction/process", { @@ -92,8 +93,8 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) - }, { "Content-Type": "application/json", - "X-MerchantId": PLATEGA_MERCHANT_ID, - "X-Secret": PLATEGA_SECRET, + "X-MerchantId": merchant_id, + "X-Secret": secret, }, ) external_id = data.get("id") or data.get("transactionId") @@ -104,14 +105,16 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) - def verify_platega_signature(raw_body: bytes, signature: str) -> bool: if not signature: return False - expected = hmac.new(PLATEGA_SECRET.encode(), raw_body, hashlib.sha256).hexdigest() + _, secret = settings.platega_credentials() + expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) def check_platega_payment(external_id: str) -> str: + merchant_id, secret = settings.platega_credentials() data = _get_json( f"https://app.platega.io/transaction/{external_id}", - {"X-MerchantId": PLATEGA_MERCHANT_ID, "X-Secret": PLATEGA_SECRET}, + {"X-MerchantId": merchant_id, "X-Secret": secret}, ) return data.get("status", "") diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..42a9a51 --- /dev/null +++ b/settings.py @@ -0,0 +1,76 @@ +import config +import legal + +PRICE_ENV_KEYS = {"7d": "PRICE_7D", "1m": "PRICE_1M", "3m": "PRICE_3M", "6m": "PRICE_6M", "1y": "PRICE_1Y"} + + +def _bool(raw: str, default: bool) -> bool: + if raw is None or raw == "": + return default + return raw.strip().lower() == "true" + + +def _positive_int(raw: str, default: int) -> int: + if raw and raw.strip().lstrip("-").isdigit(): + parsed = int(raw) + if parsed >= 0: + return parsed + return default + + +def get_plans() -> list: + raw = legal.read_env_vars(list(PRICE_ENV_KEYS.values())) + plans = [] + for p in config.PLANS: + env_key = PRICE_ENV_KEYS[p["code"]] + plans.append({ + "code": p["code"], + "label": p["label"], + "days": p["days"], + "price": _positive_int(raw.get(env_key), p["price"]), + }) + return plans + + +def get_plans_by_code() -> dict: + return {p["code"]: p for p in get_plans()} + + +def set_plan_prices(prices: dict): + for code, price in prices.items(): + if code in PRICE_ENV_KEYS: + legal.update_env_var(PRICE_ENV_KEYS[code], str(int(price))) + + +def get_payment_settings() -> dict: + raw = legal.read_env_vars(["PAYMENTS_ENABLED", "YOOKASSA_ENABLED", "PLATEGA_ENABLED"]) + return { + "payments_enabled": _bool(raw.get("PAYMENTS_ENABLED"), config.PAYMENTS_ENABLED), + "yookassa_enabled": _bool(raw.get("YOOKASSA_ENABLED"), config.YOOKASSA_ENABLED), + "platega_enabled": _bool(raw.get("PLATEGA_ENABLED"), config.PLATEGA_ENABLED), + } + + +def yookassa_credentials(): + raw = legal.read_env_vars(["YOOKASSA_SHOP_ID", "YOOKASSA_SECRET_KEY"]) + return ( + raw.get("YOOKASSA_SHOP_ID") or config.YOOKASSA_SHOP_ID, + raw.get("YOOKASSA_SECRET_KEY") or config.YOOKASSA_SECRET_KEY, + ) + + +def platega_credentials(): + raw = legal.read_env_vars(["PLATEGA_MERCHANT_ID", "PLATEGA_SECRET"]) + return ( + raw.get("PLATEGA_MERCHANT_ID") or config.PLATEGA_MERCHANT_ID, + raw.get("PLATEGA_SECRET") or config.PLATEGA_SECRET, + ) + + +def get_hwid_settings() -> dict: + raw = legal.read_env_vars(["HWID_LIMIT_ENABLED", "HWID_FALLBACK_LIMIT"]) + limit = _positive_int(raw.get("HWID_FALLBACK_LIMIT"), config.HWID_FALLBACK_LIMIT) + return { + "enabled": _bool(raw.get("HWID_LIMIT_ENABLED"), config.HWID_LIMIT_ENABLED), + "fallback_limit": limit if limit > 0 else config.HWID_FALLBACK_LIMIT, + }