diff --git a/README.md b/README.md index dd3b444..9109c07 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co Edit `.env` and set `BOT_TOKEN` to the token from BotFather. 5. **Miniapp access (calendar)** - To allow access to the calendar miniapp, set `ALLOWED_USERNAMES` to a comma-separated list of Telegram usernames (without `@`). Users in `ADMIN_USERNAMES` also have access; the admin role is reserved for future bot commands and API features. If both are empty, no one can open the calendar. + To allow access to the calendar miniapp, set `ALLOWED_USERNAMES` to a comma-separated list of Telegram usernames (without `@`). Users in `ADMIN_USERNAMES` also have access; the admin role is reserved for future bot commands and API features. If both are empty, no one can open the calendar. + **Mini App URL:** When configuring the bot's menu button or Web App URL (e.g. in @BotFather or via `setChatMenuButton`), use the URL **with a trailing slash**, e.g. `https://your-domain.com/app/`. A redirect from `/app` to `/app/` can cause the browser to drop the fragment that Telegram sends, which breaks authorization. + **How to open:** Users must open the calendar **via the bot's menu button** (⋮ → «Календарь» or the configured label) or a **Web App inline button**. If they use «Open in browser» or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied. + **BOT_TOKEN:** The server that serves `/api/duties` (e.g. your production host) must have in `.env` the **same** bot token as the bot from which users open the Mini App. If the token differs (e.g. test vs production bot), validation returns "hash_mismatch" and access is denied. 6. **Optional env** - `DATABASE_URL` – DB connection (default: `sqlite:///data/duty_teller.db`). diff --git a/api/app.py b/api/app.py index fc81982..9150982 100644 --- a/api/app.py +++ b/api/app.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from db.session import session_scope from db.repository import get_duties from db.schemas import DutyWithUser -from api.telegram_auth import validate_init_data +from api.telegram_auth import validate_init_data_with_reason log = logging.getLogger(__name__) @@ -50,6 +50,16 @@ def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]: ] +def _auth_error_detail(auth_reason: str) -> str: + """Return user-facing detail message for 403 when initData validation fails.""" + if auth_reason == "hash_mismatch": + return ( + "Неверная подпись. Убедитесь, что BOT_TOKEN на сервере совпадает с токеном бота, " + "из которого открыт календарь (тот же бот, что в меню)." + ) + return "Неверные данные авторизации" + + def _is_private_client(client_host: str | None) -> bool: """True if client is localhost or private LAN (dev / same-machine access). @@ -110,12 +120,12 @@ def list_duties( log.warning("duties: no X-Telegram-Init-Data header (client=%s)", client_host) raise HTTPException(status_code=403, detail="Откройте календарь из Telegram") max_age = config.INIT_DATA_MAX_AGE_SECONDS or None - username = validate_init_data(init_data, config.BOT_TOKEN, max_age_seconds=max_age) + username, auth_reason = validate_init_data_with_reason( + init_data, config.BOT_TOKEN, max_age_seconds=max_age + ) if username is None: - log.warning( - "duties: initData validation failed (invalid signature or no username)" - ) - raise HTTPException(status_code=403, detail="Неверные данные авторизации") + log.warning("duties: initData validation failed: %s", auth_reason) + raise HTTPException(status_code=403, detail=_auth_error_detail(auth_reason)) if not config.can_access_miniapp(username): log.warning("duties: username not in allowlist") raise HTTPException(status_code=403, detail="Доступ запрещён") diff --git a/api/telegram_auth.py b/api/telegram_auth.py index ace88bb..4c8e043 100644 --- a/api/telegram_auth.py +++ b/api/telegram_auth.py @@ -15,17 +15,23 @@ def validate_init_data( bot_token: str, max_age_seconds: int | None = None, ) -> str | None: - """ - Validate initData signature and return the Telegram username (lowercase, no @). - Returns None if data is invalid, forged, or user has no username. + """Validate initData and return username; see validate_init_data_with_reason for failure reason.""" + username, _ = validate_init_data_with_reason(init_data, bot_token, max_age_seconds) + return username - If max_age_seconds is set, initData must include auth_date and it must be no older - than max_age_seconds (replay protection). Example: 86400 = 24 hours. + +def validate_init_data_with_reason( + init_data: str, + bot_token: str, + max_age_seconds: int | None = None, +) -> tuple[str | None, str]: + """ + Validate initData signature and return (username, None) or (None, reason). + reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user", "user_invalid", "no_username". """ if not init_data or not bot_token: - return None + return (None, "empty") init_data = init_data.strip() - # Parse preserving raw values for HMAC (key -> raw value) params = {} for part in init_data.split("&"): if "=" not in part: @@ -36,46 +42,41 @@ def validate_init_data( params[key] = value hash_val = params.pop("hash", None) if not hash_val: - return None - # Build data-check string: keys sorted, format key=value per line (raw values) + return (None, "no_hash") data_pairs = sorted(params.items()) data_string = "\n".join(f"{k}={v}" for k, v in data_pairs) - # secret_key = HMAC-SHA256(key=b"WebAppData", msg=bot_token.encode()).digest() secret_key = hmac.new( b"WebAppData", msg=bot_token.encode(), digestmod=hashlib.sha256, ).digest() - # computed = HMAC-SHA256(key=secret_key, msg=data_string.encode()).hexdigest() computed = hmac.new( secret_key, msg=data_string.encode(), digestmod=hashlib.sha256, ).hexdigest() if not hmac.compare_digest(computed.lower(), hash_val.lower()): - return None - # Optional replay protection: reject initData older than max_age_seconds + return (None, "hash_mismatch") if max_age_seconds is not None and max_age_seconds > 0: auth_date_raw = params.get("auth_date") if not auth_date_raw: - return None + return (None, "auth_date_expired") try: auth_date = int(float(auth_date_raw)) except (ValueError, TypeError): - return None + return (None, "auth_date_expired") if time.time() - auth_date > max_age_seconds: - return None - # Parse user JSON (value may be URL-encoded in the raw string) + return (None, "auth_date_expired") user_raw = params.get("user") if not user_raw: - return None + return (None, "no_user") try: user = json.loads(unquote(user_raw)) except (json.JSONDecodeError, TypeError): - return None + return (None, "user_invalid") if not isinstance(user, dict): - return None + return (None, "user_invalid") username = user.get("username") if not username or not isinstance(username, str): - return None - return username.strip().lstrip("@").lower() + return (None, "no_username") + return (username.strip().lstrip("@").lower(), "ok") diff --git a/api/test_app.py b/api/test_app.py index 734d2a4..14d4caf 100644 --- a/api/test_app.py +++ b/api/test_app.py @@ -53,22 +53,23 @@ def test_duties_200_when_skip_auth(mock_fetch, client): mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31") -@patch("api.app.validate_init_data") +@patch("api.app.validate_init_data_with_reason") def test_duties_403_when_init_data_invalid(mock_validate, client): - mock_validate.return_value = None + mock_validate.return_value = (None, "hash_mismatch") r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": "some=data&hash=abc"}, ) assert r.status_code == 403 - assert "авторизации" in r.json()["detail"] or "Неверные" in r.json()["detail"] + detail = r.json()["detail"] + assert "авторизации" in detail or "Неверные" in detail or "Неверная" in detail -@patch("api.app.validate_init_data") +@patch("api.app.validate_init_data_with_reason") @patch("api.app.config.can_access_miniapp") def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client): - mock_validate.return_value = "someuser" + mock_validate.return_value = ("someuser", "ok") mock_can_access.return_value = False with patch("api.app._fetch_duties_response") as mock_fetch: r = client.get( @@ -81,10 +82,10 @@ def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, cl mock_fetch.assert_not_called() -@patch("api.app.validate_init_data") +@patch("api.app.validate_init_data_with_reason") @patch("api.app.config.can_access_miniapp") def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): - mock_validate.return_value = "alloweduser" + mock_validate.return_value = ("alloweduser", "ok") mock_can_access.return_value = True with patch("api.app._fetch_duties_response") as mock_fetch: mock_fetch.return_value = [ @@ -108,7 +109,7 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): def test_duties_e2e_auth_real_validation(client, monkeypatch): - """E2E: valid initData + allowlist, no mocks on validate_init_data; full auth path.""" + """E2E: valid initData + allowlist, no mocks on validate_init_data_with_reason; full auth path.""" test_token = "123:ABC" test_username = "e2euser" monkeypatch.setattr(config, "BOT_TOKEN", test_token) diff --git a/config.py b/config.py index ad5d620..64d3443 100644 --- a/config.py +++ b/config.py @@ -1,7 +1,6 @@ """Load configuration from environment. Fail fast if BOT_TOKEN is missing.""" import os -from pathlib import Path from dotenv import load_dotenv @@ -16,7 +15,6 @@ if not BOT_TOKEN: DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db") MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/") HTTP_PORT = int(os.getenv("HTTP_PORT", "8080")) -DATA_DIR = Path(__file__).resolve().parent / "data" # Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed. _raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip() diff --git a/db/session.py b/db/session.py index 463c9e7..251ae3c 100644 --- a/db/session.py +++ b/db/session.py @@ -1,4 +1,10 @@ -"""SQLAlchemy engine and session factory.""" +"""SQLAlchemy engine and session factory. + +Note: Engine and session factory are cached globally per process. Only one +DATABASE_URL is effectively used for the process lifetime. Using a different +URL later (e.g. in tests with in-memory SQLite) would still use the first +engine. To support multiple URLs, cache by database_url (e.g. a dict keyed by URL). +""" from contextlib import contextmanager from typing import Generator diff --git a/handlers/commands.py b/handlers/commands.py index 183628f..a1a4e67 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -39,7 +39,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: finally: session.close() - await asyncio.get_event_loop().run_in_executor(None, do_get_or_create) + await asyncio.get_running_loop().run_in_executor(None, do_get_or_create) text = "Привет! Я бот календаря дежурств. Используй /help для списка команд." if config.MINI_APP_BASE_URL: diff --git a/handlers/errors.py b/handlers/errors.py index 0526dbb..e70d98d 100644 --- a/handlers/errors.py +++ b/handlers/errors.py @@ -8,7 +8,7 @@ from telegram.ext import ContextTypes logger = logging.getLogger(__name__) -async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: +async def error_handler(update: Update | None, context: ContextTypes.DEFAULT_TYPE) -> None: logger.exception("Exception while handling an update") if isinstance(update, Update) and update.effective_message: await update.effective_message.reply_text("Произошла ошибка. Попробуйте позже.") diff --git a/main.py b/main.py index 5f2d1c2..f56ae0f 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,10 @@ """Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint.""" import asyncio +import json import logging import threading +import urllib.request import config from telegram.ext import ApplicationBuilder @@ -16,6 +18,36 @@ logging.basicConfig( logger = logging.getLogger(__name__) +def _set_default_menu_button_webapp() -> None: + """Set the bot's default menu button to Web App so Telegram sends tgWebAppData when users open the app from the menu.""" + if not (config.MINI_APP_BASE_URL and config.BOT_TOKEN): + return + menu_url = (config.MINI_APP_BASE_URL.rstrip("/") + "/app/").strip() + if not menu_url.startswith("https://"): + return + payload = { + "menu_button": { + "type": "web_app", + "text": "Календарь", + "web_app": {"url": menu_url}, + } + } + req = urllib.request.Request( + f"https://api.telegram.org/bot{config.BOT_TOKEN}/setChatMenuButton", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status == 200: + logger.info("Default menu button set to Web App: %s", menu_url) + else: + logger.warning("setChatMenuButton returned %s", resp.status) + except Exception as e: + logger.warning("Could not set menu button: %s", e) + + def _run_uvicorn(web_app, port: int) -> None: """Run uvicorn in a dedicated thread with its own event loop.""" import uvicorn @@ -29,6 +61,7 @@ def _run_uvicorn(web_app, port: int) -> None: def main() -> None: + _set_default_menu_button_webapp() app = ApplicationBuilder().token(config.BOT_TOKEN).build() register_handlers(app) diff --git a/webapp/app.js b/webapp/app.js index ace3c9a..68f3f76 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -1,4 +1,8 @@ (function () { + const FETCH_TIMEOUT_MS = 15000; + const RETRY_DELAY_MS = 800; + const RETRY_AFTER_ACCESS_DENIED_MS = 1200; + const MONTHS = [ "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" @@ -34,16 +38,33 @@ return new Date(d.getFullYear(), d.getMonth(), diff); } + /** Get tgWebAppData value from hash when it contains unencoded & and = (URLSearchParams would split it). Value runs from tgWebAppData= until next &tgWebApp or end. */ + function getTgWebAppDataFromHash(hash) { + var idx = hash.indexOf("tgWebAppData="); + if (idx === -1) return ""; + var start = idx + "tgWebAppData=".length; + var end = hash.indexOf("&tgWebApp", start); + if (end === -1) end = hash.length; + var raw = hash.substring(start, end); + try { + return decodeURIComponent(raw); + } catch (e) { + return raw; + } + } + function getInitData() { var fromSdk = (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || ""; if (fromSdk) return fromSdk; var hash = window.location.hash ? window.location.hash.slice(1) : ""; - if (hash.indexOf("tgWebAppData=") === 0) { + if (hash) { + var fromHash = getTgWebAppDataFromHash(hash); + if (fromHash) return fromHash; try { - return decodeURIComponent(hash.substring("tgWebAppData=".length)); - } catch (e) { - return hash.substring("tgWebAppData=".length); - } + var hashParams = new URLSearchParams(hash); + var tgFromHash = hashParams.get("tgWebAppData"); + if (tgFromHash) return decodeURIComponent(tgFromHash); + } catch (e) { /* ignore */ } } var q = window.location.search ? new URLSearchParams(window.location.search).get("tgWebAppData") : null; if (q) { @@ -59,9 +80,9 @@ function getInitDataDebug() { var sdk = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData); var hash = window.location.hash ? window.location.hash.slice(1) : ""; - var hashHasData = hash.indexOf("tgWebAppData=") === 0; + var hashHasData = !!(hash && (getTgWebAppDataFromHash(hash) || new URLSearchParams(hash).get("tgWebAppData"))); var queryHasData = !!(window.location.search && new URLSearchParams(window.location.search).get("tgWebAppData")); - return "SDK: " + (sdk ? "да" : "нет") + ", hash: " + (hashHasData ? hash.length + " симв." : "нет") + ", query: " + (queryHasData ? "да" : "нет"); + return "SDK: " + (sdk ? "да" : "нет") + ", hash: " + (hashHasData ? hash.length + " симв." : (hash ? "есть, без tgWebAppData" : "нет")) + ", query: " + (queryHasData ? "да" : "нет"); } function isLocalhost() { @@ -69,7 +90,19 @@ return h === "localhost" || h === "127.0.0.1" || h === ""; } + function hasTelegramHashButNoInitData() { + var hash = window.location.hash ? window.location.hash.slice(1) : ""; + if (!hash) return false; + try { + var keys = Array.from(new URLSearchParams(hash).keys()); + var hasVersion = keys.indexOf("tgWebAppVersion") !== -1; + var hasData = keys.indexOf("tgWebAppData") !== -1 || getTgWebAppDataFromHash(hash); + return hasVersion && !hasData; + } catch (e) { return false; } + } + function showAccessDenied() { + var debugStr = getInitDataDebug(); if (headerEl) headerEl.hidden = true; if (weekdaysEl) weekdaysEl.hidden = true; calendarEl.hidden = true; @@ -78,7 +111,13 @@ errorEl.hidden = true; accessDeniedEl.hidden = false; var debugEl = document.getElementById("accessDeniedDebug"); - if (debugEl) debugEl.textContent = getInitDataDebug(); + if (debugEl) debugEl.textContent = debugStr; + var hintEl = document.getElementById("accessDeniedHint"); + if (hintEl) { + hintEl.textContent = hasTelegramHashButNoInitData() + ? "Откройте календарь через кнопку меню бота (⋮ или «Календарь»), а не через «Открыть в браузере» или прямую ссылку." + : "Откройте календарь из Telegram."; + } } function hideAccessDenied() { @@ -96,21 +135,19 @@ const headers = {}; if (initData) headers["X-Telegram-Init-Data"] = initData; var controller = new AbortController(); - var timeoutId = setTimeout(function () { controller.abort(); }, 15000); + var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS); try { var res = await fetch(url, { headers: headers, signal: controller.signal }); - clearTimeout(timeoutId); - if (res.status === 403) { - throw new Error("ACCESS_DENIED"); - } + if (res.status === 403) throw new Error("ACCESS_DENIED"); if (!res.ok) throw new Error("Ошибка загрузки"); return res.json(); } catch (e) { - clearTimeout(timeoutId); if (e.name === "AbortError") { throw new Error("Не удалось загрузить данные. Проверьте интернет."); } throw e; + } finally { + clearTimeout(timeoutId); } } @@ -206,6 +243,25 @@ } } + /** If allowed (initData or localhost), call onAllowed(); otherwise show access denied. When inside Telegram WebApp but initData is empty, retry once after a short delay (initData may be set asynchronously). */ + function requireTelegramOrLocalhost(onAllowed) { + var initData = getInitData(); + var isLocal = isLocalhost(); + if (initData) { onAllowed(); return; } + if (isLocal) { onAllowed(); return; } + if (window.Telegram && window.Telegram.WebApp) { + setTimeout(function () { + initData = getInitData(); + if (initData) { onAllowed(); return; } + showAccessDenied(); + loadingEl.classList.add("hidden"); + }, RETRY_DELAY_MS); + return; + } + showAccessDenied(); + loadingEl.classList.add("hidden"); + } + function setNavEnabled(enabled) { if (prevBtn) prevBtn.disabled = !enabled; if (nextBtn) nextBtn.disabled = !enabled; @@ -229,7 +285,7 @@ setNavEnabled(true); if (window.Telegram && window.Telegram.WebApp && !window._initDataRetried) { window._initDataRetried = true; - setTimeout(loadMonth, 1200); + setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS); } return; } @@ -250,5 +306,9 @@ loadMonth(); }); - runWhenReady(loadMonth); + runWhenReady(function () { + requireTelegramOrLocalhost(function () { + loadMonth(); + }); + }); })(); diff --git a/webapp/index.html b/webapp/index.html index 6bdda3a..ec8141a 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -22,7 +22,7 @@
Доступ запрещён.
-Откройте календарь из Telegram.
+Откройте календарь из Telegram.