diff --git a/.dockerignore b/.dockerignore index 266ef28..6679676 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ __pycache__/ *.md .cursor/ *.plan.md +data/ diff --git a/.env.example b/.env.example index 1c9c133..e026cb4 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,7 @@ BOT_TOKEN=your_bot_token_here DATABASE_URL=sqlite:///data/duty_teller.db MINI_APP_BASE_URL= HTTP_PORT=8080 + +# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed. +ALLOWED_USERNAMES=username1,username2 +ADMIN_USERNAMES=admin1,admin2 diff --git a/.gitignore b/.gitignore index 8b746f3..06716c7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ venv/ *.pyc *.pyo +data/ +*.db diff --git a/README.md b/README.md index 13a6075..b0197b5 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ 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. + ## Run ```bash @@ -60,7 +63,7 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`. ## Project layout - `main.py` – Builds the `Application`, registers handlers, runs polling. -- `config.py` – Loads `BOT_TOKEN` from env; exits if missing. +- `config.py` – Loads `BOT_TOKEN`, `ALLOWED_USERNAMES`, `ADMIN_USERNAMES` from env; exits if `BOT_TOKEN` is missing. - `handlers/` – Command and error handlers; add new handlers here. - `requirements.txt` – Pinned dependencies (PTB with job-queue, python-dotenv). diff --git a/api/app.py b/api/app.py index eb1ad61..413dff7 100644 --- a/api/app.py +++ b/api/app.py @@ -2,13 +2,14 @@ from pathlib import Path import config -from fastapi import FastAPI, Query +from fastapi import FastAPI, Header, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from db.session import get_session from db.repository import get_duties from db.schemas import DutyWithUser +from api.telegram_auth import validate_init_data app = FastAPI(title="Duty Teller API") app.add_middleware( @@ -24,7 +25,16 @@ app.add_middleware( def list_duties( from_date: str = Query(..., description="ISO date YYYY-MM-DD"), to_date: str = Query(..., description="ISO date YYYY-MM-DD"), + x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"), ) -> list[DutyWithUser]: + init_data = (x_telegram_init_data or "").strip() + if not init_data: + raise HTTPException(status_code=403, detail="Откройте календарь из Telegram") + username = validate_init_data(init_data, config.BOT_TOKEN) + if username is None: + raise HTTPException(status_code=403, detail="Неверные данные авторизации") + if not config.can_access_miniapp(username): + raise HTTPException(status_code=403, detail="Доступ запрещён") session = get_session(config.DATABASE_URL) try: rows = get_duties(session, from_date=from_date, to_date=to_date) diff --git a/api/telegram_auth.py b/api/telegram_auth.py new file mode 100644 index 0000000..e3a057f --- /dev/null +++ b/api/telegram_auth.py @@ -0,0 +1,61 @@ +"""Validate Telegram Web App initData and extract user username.""" +import hashlib +import hmac +import json +from urllib.parse import unquote + +# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app +# Data-check string must use the same key=value pairs as received (sorted by key); we preserve raw values. + + +def validate_init_data(init_data: str, bot_token: str) -> str | None: + """ + Validate initData signature and return the Telegram username (lowercase, no @). + Returns None if data is invalid, forged, or user has no username. + """ + if not init_data or not bot_token: + return None + 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: + continue + key, _, value = part.partition("=") + if not key: + continue + 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) + 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 + # Parse user JSON (value may be URL-encoded in the raw string) + user_raw = params.get("user") + if not user_raw: + return None + try: + user = json.loads(unquote(user_raw)) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(user, dict): + return None + username = user.get("username") + if not username or not isinstance(username, str): + return None + return username.strip().lstrip("@").lower() diff --git a/config.py b/config.py index c87eadd..937f542 100644 --- a/config.py +++ b/config.py @@ -14,3 +14,21 @@ 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() +ALLOWED_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_allowed.split(",") if s.strip()} + +_raw_admin = os.getenv("ADMIN_USERNAMES", "").strip() +ADMIN_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()} + + +def is_admin(username: str) -> bool: + """True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES.""" + return (username or "").strip().lower() in ADMIN_USERNAMES + + +def can_access_miniapp(username: str) -> bool: + """True if username is in ALLOWED_USERNAMES or ADMIN_USERNAMES.""" + u = (username or "").strip().lower() + return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES diff --git a/webapp/app.js b/webapp/app.js index 12d77f3..9c6eef6 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -10,6 +10,9 @@ const dutyListEl = document.getElementById("dutyList"); const loadingEl = document.getElementById("loading"); const errorEl = document.getElementById("error"); + const accessDeniedEl = document.getElementById("accessDenied"); + const headerEl = document.querySelector(".header"); + const weekdaysEl = document.querySelector(".weekdays"); function isoDate(d) { return d.toISOString().slice(0, 10); @@ -29,10 +32,38 @@ return new Date(d.getFullYear(), d.getMonth(), diff); } + function getInitData() { + return (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || ""; + } + + function showAccessDenied() { + if (headerEl) headerEl.hidden = true; + if (weekdaysEl) weekdaysEl.hidden = true; + calendarEl.hidden = true; + dutyListEl.hidden = true; + loadingEl.classList.add("hidden"); + errorEl.hidden = true; + accessDeniedEl.hidden = false; + } + + function hideAccessDenied() { + accessDeniedEl.hidden = true; + if (headerEl) headerEl.hidden = false; + if (weekdaysEl) weekdaysEl.hidden = false; + calendarEl.hidden = false; + dutyListEl.hidden = false; + } + async function fetchDuties(from, to) { const base = window.location.origin; const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to); - const res = await fetch(url); + const initData = getInitData(); + const headers = {}; + if (initData) headers["X-Telegram-Init-Data"] = initData; + const res = await fetch(url, { headers: headers }); + if (res.status === 403) { + throw new Error("ACCESS_DENIED"); + } if (!res.ok) throw new Error("Ошибка загрузки"); return res.json(); } @@ -116,6 +147,11 @@ } async function loadMonth() { + if (!getInitData()) { + showAccessDenied(); + return; + } + hideAccessDenied(); loadingEl.classList.remove("hidden"); errorEl.hidden = true; const from = isoDate(firstDayOfMonth(current)); @@ -126,6 +162,10 @@ renderCalendar(current.getFullYear(), current.getMonth(), byDate); renderDutyList(duties); } catch (e) { + if (e.message === "ACCESS_DENIED") { + showAccessDenied(); + return; + } showError(e.message || "Не удалось загрузить данные."); return; } diff --git a/webapp/index.html b/webapp/index.html index e2cf88a..1df6f62 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -20,7 +20,12 @@
Загрузка…
+ + diff --git a/webapp/style.css b/webapp/style.css index 7b2badb..1918c20 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -150,3 +150,22 @@ body { .error[hidden], .loading.hidden { display: none !important; } + +.access-denied { + text-align: center; + padding: 24px 12px; + color: var(--muted); +} + +.access-denied p { + margin: 0 0 8px 0; +} + +.access-denied p:first-child { + color: #f7768e; + font-weight: 600; +} + +.access-denied[hidden] { + display: none !important; +}