Update configuration and access control for Telegram miniapp

- Added ALLOWED_USERNAMES and ADMIN_USERNAMES to .env.example for user access control.
- Implemented validation of Telegram Web App initData in a new telegram_auth.py module.
- Enhanced API to check user access before fetching duties.
- Updated README with instructions for configuring miniapp access.
- Modified .dockerignore and .gitignore to include data directory and database files.
This commit is contained in:
2026-02-17 13:10:45 +03:00
parent d60a4fdf3f
commit 57c24a79af
10 changed files with 166 additions and 3 deletions

View File

@@ -7,3 +7,4 @@ __pycache__/
*.md *.md
.cursor/ .cursor/
*.plan.md *.plan.md
data/

View File

@@ -2,3 +2,7 @@ BOT_TOKEN=your_bot_token_here
DATABASE_URL=sqlite:///data/duty_teller.db DATABASE_URL=sqlite:///data/duty_teller.db
MINI_APP_BASE_URL= MINI_APP_BASE_URL=
HTTP_PORT=8080 HTTP_PORT=8080
# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed.
ALLOWED_USERNAMES=username1,username2
ADMIN_USERNAMES=admin1,admin2

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ __pycache__/
venv/ venv/
*.pyc *.pyc
*.pyo *.pyo
data/
*.db

View File

@@ -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. 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 ## Run
```bash ```bash
@@ -60,7 +63,7 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
## Project layout ## Project layout
- `main.py` Builds the `Application`, registers handlers, runs polling. - `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. - `handlers/` Command and error handlers; add new handlers here.
- `requirements.txt` Pinned dependencies (PTB with job-queue, python-dotenv). - `requirements.txt` Pinned dependencies (PTB with job-queue, python-dotenv).

View File

@@ -2,13 +2,14 @@
from pathlib import Path from pathlib import Path
import config import config
from fastapi import FastAPI, Query from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from db.session import get_session from db.session import get_session
from db.repository import get_duties from db.repository import get_duties
from db.schemas import DutyWithUser from db.schemas import DutyWithUser
from api.telegram_auth import validate_init_data
app = FastAPI(title="Duty Teller API") app = FastAPI(title="Duty Teller API")
app.add_middleware( app.add_middleware(
@@ -24,7 +25,16 @@ app.add_middleware(
def list_duties( def list_duties(
from_date: str = Query(..., description="ISO date YYYY-MM-DD"), from_date: str = Query(..., description="ISO date YYYY-MM-DD"),
to_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]: ) -> 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) session = get_session(config.DATABASE_URL)
try: try:
rows = get_duties(session, from_date=from_date, to_date=to_date) rows = get_duties(session, from_date=from_date, to_date=to_date)

61
api/telegram_auth.py Normal file
View File

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

View File

@@ -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("/") MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080")) HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
DATA_DIR = Path(__file__).resolve().parent / "data" 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

View File

@@ -10,6 +10,9 @@
const dutyListEl = document.getElementById("dutyList"); const dutyListEl = document.getElementById("dutyList");
const loadingEl = document.getElementById("loading"); const loadingEl = document.getElementById("loading");
const errorEl = document.getElementById("error"); const errorEl = document.getElementById("error");
const accessDeniedEl = document.getElementById("accessDenied");
const headerEl = document.querySelector(".header");
const weekdaysEl = document.querySelector(".weekdays");
function isoDate(d) { function isoDate(d) {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
@@ -29,10 +32,38 @@
return new Date(d.getFullYear(), d.getMonth(), diff); 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) { async function fetchDuties(from, to) {
const base = window.location.origin; const base = window.location.origin;
const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to); 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("Ошибка загрузки"); if (!res.ok) throw new Error("Ошибка загрузки");
return res.json(); return res.json();
} }
@@ -116,6 +147,11 @@
} }
async function loadMonth() { async function loadMonth() {
if (!getInitData()) {
showAccessDenied();
return;
}
hideAccessDenied();
loadingEl.classList.remove("hidden"); loadingEl.classList.remove("hidden");
errorEl.hidden = true; errorEl.hidden = true;
const from = isoDate(firstDayOfMonth(current)); const from = isoDate(firstDayOfMonth(current));
@@ -126,6 +162,10 @@
renderCalendar(current.getFullYear(), current.getMonth(), byDate); renderCalendar(current.getFullYear(), current.getMonth(), byDate);
renderDutyList(duties); renderDutyList(duties);
} catch (e) { } catch (e) {
if (e.message === "ACCESS_DENIED") {
showAccessDenied();
return;
}
showError(e.message || "Не удалось загрузить данные."); showError(e.message || "Не удалось загрузить данные.");
return; return;
} }

View File

@@ -20,7 +20,12 @@
<div class="duty-list" id="dutyList"></div> <div class="duty-list" id="dutyList"></div>
<div class="loading" id="loading">Загрузка…</div> <div class="loading" id="loading">Загрузка…</div>
<div class="error" id="error" hidden></div> <div class="error" id="error" hidden></div>
<div class="access-denied" id="accessDenied" hidden>
<p>Доступ запрещён.</p>
<p class="muted">Откройте календарь из Telegram.</p>
</div>
</div> </div>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@@ -150,3 +150,22 @@ body {
.error[hidden], .loading.hidden { .error[hidden], .loading.hidden {
display: none !important; 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;
}