Enhance API and configuration for Telegram miniapp

- Added support for CORS origins and a new environment variable for miniapp access control.
- Implemented date validation for API requests to ensure correct date formats.
- Updated FastAPI app to allow access without Telegram initData for local development.
- Enhanced error handling and logging for better debugging.
- Added tests for API functionality and Telegram initData validation.
- Updated README with new environment variable details and testing instructions.
- Modified Docker and Git ignore files to include additional directories and files.
This commit is contained in:
2026-02-17 17:21:35 +03:00
parent 7cdf1edc34
commit 5dc8c8f255
19 changed files with 447 additions and 64 deletions

View File

@@ -1,4 +1,5 @@
venv/ venv/
.venv/
.env .env
.git/ .git/
__pycache__/ __pycache__/
@@ -8,3 +9,4 @@ __pycache__/
.cursor/ .cursor/
*.plan.md *.plan.md
data/ data/
.curosr/

View File

@@ -6,3 +6,6 @@ HTTP_PORT=8080
# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed. # Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed.
ALLOWED_USERNAMES=username1,username2 ALLOWED_USERNAMES=username1,username2
ADMIN_USERNAMES=admin1,admin2 ADMIN_USERNAMES=admin1,admin2
# Dev only: set to 1 to allow calendar without Telegram initData (insecure; do not use in production).
# MINI_APP_SKIP_AUTH=1

2
.gitignore vendored
View File

@@ -1,7 +1,9 @@
.env .env
__pycache__/ __pycache__/
venv/ venv/
.venv/
*.pyc *.pyc
*.pyo *.pyo
data/ data/
*.db *.db
.cursor/

View File

@@ -36,6 +36,12 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
5. **Miniapp access (calendar)** 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.
6. **Optional env**
- `DATABASE_URL` DB connection (default: `sqlite:///data/duty_teller.db`).
- `MINI_APP_BASE_URL` Base URL of the miniapp (for documentation / CORS).
- `MINI_APP_SKIP_AUTH` Set to `1` to allow `/api/duties` without Telegram initData (dev only; insecure).
- `CORS_ORIGINS` Comma-separated allowed origins for CORS, or leave unset for `*`.
## Run ## Run
```bash ```bash
@@ -62,9 +68,24 @@ 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 and FastAPI in a thread.
- `config.py` Loads `BOT_TOKEN`, `ALLOWED_USERNAMES`, `ADMIN_USERNAMES` from env; exits if `BOT_TOKEN` is missing. - `config.py` Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, `ADMIN_USERNAMES`, `CORS_ORIGINS`, etc. from env; exits if `BOT_TOKEN` is missing.
- `api/` FastAPI app (`/api/duties`), Telegram initData validation, static webapp mount.
- `db/` SQLAlchemy models, session, repository, schemas.
- `alembic/` Migrations (use `config.DATABASE_URL`).
- `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). - `webapp/` Miniapp UI (calendar, duty list); served at `/app`.
- `requirements.txt` Pinned dependencies (PTB, FastAPI, SQLAlchemy, Alembic, etc.).
To add commands, define async handlers in `handlers/commands.py` (or a new module) and register them in `handlers/__init__.py`. To add commands, define async handlers in `handlers/commands.py` (or a new module) and register them in `handlers/__init__.py`.
## Tests
Install dev dependencies and run pytest:
```bash
pip install -r requirements-dev.txt
pytest
```
Tests cover `api/telegram_auth` (validate_init_data), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth).

View File

@@ -3,22 +3,20 @@ import os
import sys import sys
from logging.config import fileConfig from logging.config import fileConfig
from dotenv import load_dotenv
from sqlalchemy import create_engine from sqlalchemy import create_engine
from alembic import context from alembic import context
load_dotenv()
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config
from db.models import Base from db.models import Base
config = context.config config_alembic = context.config
if config.config_file_name is not None: if config_alembic.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config_alembic.config_file_name)
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db") database_url = config.DATABASE_URL
config.set_main_option("sqlalchemy.url", database_url) config_alembic.set_main_option("sqlalchemy.url", database_url)
target_metadata = Base.metadata target_metadata = Base.metadata

View File

@@ -1,4 +1,6 @@
"""FastAPI app: /api/duties and static webapp.""" """FastAPI app: /api/duties and static webapp."""
import logging
import re
from pathlib import Path from pathlib import Path
import config import config
@@ -6,15 +8,68 @@ from fastapi import FastAPI, Header, HTTPException, Query, Request
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 session_scope
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 from api.telegram_auth import validate_init_data
log = logging.getLogger(__name__)
# ISO date YYYY-MM-DD
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _validate_duty_dates(from_date: str, to_date: str) -> None:
"""Raise HTTPException 400 if dates are invalid or from_date > to_date."""
if not _DATE_RE.match(from_date) or not _DATE_RE.match(to_date):
raise HTTPException(
status_code=400,
detail="Параметры from и to должны быть в формате YYYY-MM-DD",
)
if from_date > to_date:
raise HTTPException(
status_code=400,
detail="Дата from не должна быть позже to",
)
def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]:
"""Fetch duties in range and return list of DutyWithUser. Uses config.DATABASE_URL."""
with session_scope(config.DATABASE_URL) as session:
rows = get_duties(session, from_date=from_date, to_date=to_date)
return [
DutyWithUser(
id=duty.id,
user_id=duty.user_id,
start_at=duty.start_at,
end_at=duty.end_at,
full_name=full_name,
)
for duty, full_name in rows
]
def _is_private_client(client_host: str | None) -> bool:
"""True if client is localhost or private LAN (dev / same-machine access)."""
if not client_host:
return False
if client_host in ("127.0.0.1", "::1"):
return True
parts = client_host.split(".")
if len(parts) == 4: # IPv4
try:
a, b, c, d = (int(x) for x in parts)
if (a == 10) or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168):
return True
except (ValueError, IndexError):
pass
return False
app = FastAPI(title="Duty Teller API") app = FastAPI(title="Duty Teller API")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=config.CORS_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -28,47 +83,25 @@ def list_duties(
to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"), to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"),
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"), x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
) -> list[DutyWithUser]: ) -> list[DutyWithUser]:
_validate_duty_dates(from_date, to_date)
log.info("GET /api/duties from %s, has initData: %s", request.client.host if request.client else "?", bool((x_telegram_init_data or "").strip()))
init_data = (x_telegram_init_data or "").strip() init_data = (x_telegram_init_data or "").strip()
if not init_data: if not init_data:
# Allow access from localhost without Telegram initData (local dev only)
client_host = request.client.host if request.client else None client_host = request.client.host if request.client else None
if client_host in ("127.0.0.1", "::1"): if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH:
session = get_session(config.DATABASE_URL) if config.MINI_APP_SKIP_AUTH:
try: log.warning("duties: allowing without initData (MINI_APP_SKIP_AUTH is set)")
rows = get_duties(session, from_date=from_date, to_date=to_date) return _fetch_duties_response(from_date, to_date)
return [ log.warning("duties: no X-Telegram-Init-Data header (client=%s)", client_host)
DutyWithUser(
id=duty.id,
user_id=duty.user_id,
start_at=duty.start_at,
end_at=duty.end_at,
full_name=full_name,
)
for duty, full_name in rows
]
finally:
session.close()
raise HTTPException(status_code=403, detail="Откройте календарь из Telegram") raise HTTPException(status_code=403, detail="Откройте календарь из Telegram")
username = validate_init_data(init_data, config.BOT_TOKEN) username = validate_init_data(init_data, config.BOT_TOKEN)
if username is None: if username is None:
log.warning("duties: initData validation failed (invalid signature or no username)")
raise HTTPException(status_code=403, detail="Неверные данные авторизации") raise HTTPException(status_code=403, detail="Неверные данные авторизации")
if not config.can_access_miniapp(username): if not config.can_access_miniapp(username):
log.warning("duties: username not in allowlist")
raise HTTPException(status_code=403, detail="Доступ запрещён") raise HTTPException(status_code=403, detail="Доступ запрещён")
session = get_session(config.DATABASE_URL) return _fetch_duties_response(from_date, to_date)
try:
rows = get_duties(session, from_date=from_date, to_date=to_date)
return [
DutyWithUser(
id=duty.id,
user_id=duty.user_id,
start_at=duty.start_at,
end_at=duty.end_at,
full_name=full_name,
)
for duty, full_name in rows
]
finally:
session.close()
webapp_path = Path(__file__).resolve().parent.parent / "webapp" webapp_path = Path(__file__).resolve().parent.parent / "webapp"

103
api/test_app.py Normal file
View File

@@ -0,0 +1,103 @@
"""Tests for FastAPI app /api/duties."""
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from api.app import app
@pytest.fixture
def client():
return TestClient(app)
def test_duties_invalid_date_format(client):
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
assert r.status_code == 400
assert "YYYY-MM-DD" in r.json()["detail"]
def test_duties_from_after_to(client):
r = client.get("/api/duties", params={"from": "2025-02-01", "to": "2025-01-01"})
assert r.status_code == 400
assert "from" in r.json()["detail"].lower() or "позже" in r.json()["detail"]
@patch("api.app._is_private_client")
@patch("api.app.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_without_init_data_from_public_client(mock_private, client):
"""Without initData and without private IP / skip-auth, should get 403."""
mock_private.return_value = False # simulate public client
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
)
assert r.status_code == 403
@patch("api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("api.app._fetch_duties_response")
def test_duties_200_when_skip_auth(mock_fetch, client):
mock_fetch.return_value = []
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
)
assert r.status_code == 200
assert r.json() == []
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")
@patch("api.app.validate_init_data")
def test_duties_403_when_init_data_invalid(mock_validate, client):
mock_validate.return_value = None
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"]
@patch("api.app.validate_init_data")
@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_can_access.return_value = False
with patch("api.app._fetch_duties_response") as mock_fetch:
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
)
assert r.status_code == 403
assert "Доступ запрещён" in r.json()["detail"]
mock_fetch.assert_not_called()
@patch("api.app.validate_init_data")
@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_can_access.return_value = True
with patch("api.app._fetch_duties_response") as mock_fetch:
mock_fetch.return_value = [
{
"id": 1,
"user_id": 10,
"start_at": "2025-01-15T09:00:00",
"end_at": "2025-01-15T18:00:00",
"full_name": "Иван Иванов",
}
]
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
)
assert r.status_code == 200
assert len(r.json()) == 1
assert r.json()[0]["full_name"] == "Иван Иванов"
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")

84
api/test_telegram_auth.py Normal file
View File

@@ -0,0 +1,84 @@
"""Tests for api.telegram_auth.validate_init_data."""
import hashlib
import hmac
import json
from urllib.parse import quote, urlencode
import pytest
from api.telegram_auth import validate_init_data
def _make_init_data(user: dict | None, bot_token: str) -> str:
"""Build initData string with valid HMAC for testing."""
params = {}
if user is not None:
params["user"] = quote(json.dumps(user))
pairs = sorted(params.items())
data_string = "\n".join(f"{k}={v}" for k, v in pairs)
secret_key = hmac.new(
b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed = hmac.new(
secret_key,
msg=data_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
params["hash"] = computed
return "&".join(f"{k}={v}" for k, v in sorted(params.items()))
def test_valid_payload_returns_username():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser", "first_name": "Test"}
init_data = _make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) == "testuser"
def test_valid_payload_username_lowercase():
bot_token = "123:ABC"
user = {"id": 123, "username": "TestUser", "first_name": "Test"}
init_data = _make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) == "testuser"
def test_invalid_hash_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser"}
init_data = _make_init_data(user, bot_token)
# Tamper with hash
init_data = init_data.replace("hash=", "hash=x")
assert validate_init_data(init_data, bot_token) is None
def test_wrong_bot_token_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser"}
init_data = _make_init_data(user, bot_token)
assert validate_init_data(init_data, "other:token") is None
def test_missing_user_returns_none():
bot_token = "123:ABC"
init_data = _make_init_data(None, bot_token) # no user key
assert validate_init_data(init_data, bot_token) is None
def test_user_without_username_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "first_name": "Test"} # no username
init_data = _make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) is None
def test_empty_init_data_returns_none():
assert validate_init_data("", "token") is None
assert validate_init_data(" ", "token") is None
def test_empty_bot_token_returns_none():
user = {"id": 1, "username": "u"}
init_data = _make_init_data(user, "token")
assert validate_init_data(init_data, "") is None

View File

@@ -22,6 +22,13 @@ ALLOWED_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_allowed.split("
_raw_admin = os.getenv("ADMIN_USERNAMES", "").strip() _raw_admin = os.getenv("ADMIN_USERNAMES", "").strip()
ADMIN_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()} ADMIN_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()}
# Dev only: set to 1 to allow /api/duties without Telegram initData (insecure, no user check).
MINI_APP_SKIP_AUTH = os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes")
# CORS: comma-separated origins, or empty/"*" for allow all. For production, set to MINI_APP_BASE_URL or specific origins.
_raw_cors = os.getenv("CORS_ORIGINS", "").strip()
CORS_ORIGINS = [_o.strip() for _o in _raw_cors.split(",") if _o.strip()] if _raw_cors and _raw_cors != "*" else ["*"]
def is_admin(username: str) -> bool: def is_admin(username: str) -> bool:
"""True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES.""" """True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES."""

6
conftest.py Normal file
View File

@@ -0,0 +1,6 @@
"""Pytest configuration. Set BOT_TOKEN so config module can be imported."""
import os
# Set before any project code imports config (which requires BOT_TOKEN).
if not os.environ.get("BOT_TOKEN"):
os.environ["BOT_TOKEN"] = "test-token-for-pytest"

View File

@@ -1,4 +1,7 @@
"""SQLAlchemy engine and session factory.""" """SQLAlchemy engine and session factory."""
from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -8,6 +11,16 @@ _engine = None
_SessionLocal = None _SessionLocal = None
@contextmanager
def session_scope(database_url: str) -> Generator[Session, None, None]:
"""Context manager: yields a session and closes it on exit."""
session = get_session(database_url)
try:
yield session
finally:
session.close()
def get_engine(database_url: str): def get_engine(database_url: str):
global _engine global _engine
if _engine is None: if _engine is None:

View File

@@ -2,7 +2,7 @@
import asyncio import asyncio
import config import config
from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup from telegram import Update, WebAppInfo, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import CommandHandler, ContextTypes from telegram.ext import CommandHandler, ContextTypes
from db.session import get_session from db.session import get_session
@@ -39,10 +39,9 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = "Привет! Я бот календаря дежурств. Используй /help для списка команд." text = "Привет! Я бот календаря дежурств. Используй /help для списка команд."
if config.MINI_APP_BASE_URL: if config.MINI_APP_BASE_URL:
keyboard = ReplyKeyboardMarkup( keyboard = InlineKeyboardMarkup([
[[KeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))]], [InlineKeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))],
resize_keyboard=True, ])
)
await update.message.reply_text(text, reply_markup=keyboard) await update.message.reply_text(text, reply_markup=keyboard)
else: else:
await update.message.reply_text(text) await update.message.reply_text(text)

View File

@@ -10,4 +10,4 @@ logger = logging.getLogger(__name__)
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
logger.exception("Exception while handling an update") logger.exception("Exception while handling an update")
if isinstance(update, Update) and update.effective_message: if isinstance(update, Update) and update.effective_message:
await update.effective_message.reply_text("Something went wrong. Please try again later.") await update.effective_message.reply_text("Произошла ошибка. Попробуйте позже.")

4
requirements-dev.txt Normal file
View File

@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.0,<9.0
pytest-asyncio>=0.24,<1.0
httpx>=0.27,<1.0

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Tests package.

37
tests/test_config.py Normal file
View File

@@ -0,0 +1,37 @@
"""Tests for config.is_admin and config.can_access_miniapp."""
import pytest
import config
def test_is_admin_true_when_in_admin_list(monkeypatch):
monkeypatch.setattr(config, "ADMIN_USERNAMES", {"admin1", "admin2"})
assert config.is_admin("admin1") is True
assert config.is_admin("ADMIN1") is True
assert config.is_admin("admin2") is True
def test_is_admin_false_when_not_in_list(monkeypatch):
monkeypatch.setattr(config, "ADMIN_USERNAMES", {"admin1"})
assert config.is_admin("other") is False
assert config.is_admin("") is False
def test_can_access_miniapp_allowed_username(monkeypatch):
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {"user1"})
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
assert config.can_access_miniapp("user1") is True
assert config.can_access_miniapp("USER1") is True
def test_can_access_miniapp_admin_has_access(monkeypatch):
monkeypatch.setattr(config, "ALLOWED_USERNAMES", set())
monkeypatch.setattr(config, "ADMIN_USERNAMES", {"admin1"})
assert config.can_access_miniapp("admin1") is True
def test_can_access_miniapp_denied(monkeypatch):
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {"user1"})
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
assert config.can_access_miniapp("other") is False
assert config.can_access_miniapp("") is False

View File

@@ -13,6 +13,8 @@
const accessDeniedEl = document.getElementById("accessDenied"); const accessDeniedEl = document.getElementById("accessDenied");
const headerEl = document.querySelector(".header"); const headerEl = document.querySelector(".header");
const weekdaysEl = document.querySelector(".weekdays"); const weekdaysEl = document.querySelector(".weekdays");
const prevBtn = document.getElementById("prevMonth");
const nextBtn = document.getElementById("nextMonth");
function isoDate(d) { function isoDate(d) {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
@@ -33,7 +35,33 @@
} }
function getInitData() { function getInitData() {
return (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || ""; 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) {
try {
return decodeURIComponent(hash.substring("tgWebAppData=".length));
} catch (e) {
return hash.substring("tgWebAppData=".length);
}
}
var q = window.location.search ? new URLSearchParams(window.location.search).get("tgWebAppData") : null;
if (q) {
try {
return decodeURIComponent(q);
} catch (e) {
return q;
}
}
return "";
}
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 queryHasData = !!(window.location.search && new URLSearchParams(window.location.search).get("tgWebAppData"));
return "SDK: " + (sdk ? "да" : "нет") + ", hash: " + (hashHasData ? hash.length + " симв." : "нет") + ", query: " + (queryHasData ? "да" : "нет");
} }
function isLocalhost() { function isLocalhost() {
@@ -49,6 +77,8 @@
loadingEl.classList.add("hidden"); loadingEl.classList.add("hidden");
errorEl.hidden = true; errorEl.hidden = true;
accessDeniedEl.hidden = false; accessDeniedEl.hidden = false;
var debugEl = document.getElementById("accessDeniedDebug");
if (debugEl) debugEl.textContent = getInitDataDebug();
} }
function hideAccessDenied() { function hideAccessDenied() {
@@ -65,12 +95,23 @@
const initData = getInitData(); const initData = getInitData();
const headers = {}; const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData; if (initData) headers["X-Telegram-Init-Data"] = initData;
const res = await fetch(url, { headers: headers }); var controller = new AbortController();
if (res.status === 403) { var timeoutId = setTimeout(function () { controller.abort(); }, 15000);
throw new Error("ACCESS_DENIED"); try {
var res = await fetch(url, { headers: headers, signal: controller.signal });
clearTimeout(timeoutId);
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;
} }
if (!res.ok) throw new Error("Ошибка загрузки");
return res.json();
} }
function renderCalendar(year, month, dutiesByDate) { function renderCalendar(year, month, dutiesByDate) {
@@ -92,7 +133,7 @@
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : ""); cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "");
cell.innerHTML = cell.innerHTML =
"<span class=\"num\">" + d.getDate() + "</span>" + "<span class=\"num\">" + d.getDate() + "</span>" +
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return x.full_name; }).join(", ") + "</span>" : ""); (dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "");
calendarEl.appendChild(cell); calendarEl.appendChild(cell);
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
} }
@@ -151,13 +192,28 @@
loadingEl.classList.add("hidden"); loadingEl.classList.add("hidden");
} }
async function loadMonth() { function runWhenReady(cb) {
var _initData = getInitData(); if (window.Telegram && window.Telegram.WebApp) {
if (!_initData && !isLocalhost()) { if (window.Telegram.WebApp.ready) {
showAccessDenied(); window.Telegram.WebApp.ready();
return; }
if (window.Telegram.WebApp.expand) {
window.Telegram.WebApp.expand();
}
setTimeout(cb, 0);
} else {
cb();
} }
}
function setNavEnabled(enabled) {
if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.disabled = !enabled;
}
async function loadMonth() {
hideAccessDenied(); hideAccessDenied();
setNavEnabled(false);
loadingEl.classList.remove("hidden"); loadingEl.classList.remove("hidden");
errorEl.hidden = true; errorEl.hidden = true;
const from = isoDate(firstDayOfMonth(current)); const from = isoDate(firstDayOfMonth(current));
@@ -170,12 +226,19 @@
} catch (e) { } catch (e) {
if (e.message === "ACCESS_DENIED") { if (e.message === "ACCESS_DENIED") {
showAccessDenied(); showAccessDenied();
setNavEnabled(true);
if (window.Telegram && window.Telegram.WebApp && !window._initDataRetried) {
window._initDataRetried = true;
setTimeout(loadMonth, 1200);
}
return; return;
} }
showError(e.message || "Не удалось загрузить данные."); showError(e.message || "Не удалось загрузить данные.");
setNavEnabled(true);
return; return;
} }
loadingEl.classList.add("hidden"); loadingEl.classList.add("hidden");
setNavEnabled(true);
} }
document.getElementById("prevMonth").addEventListener("click", function () { document.getElementById("prevMonth").addEventListener("click", function () {
@@ -187,5 +250,5 @@
loadMonth(); loadMonth();
}); });
loadMonth(); runWhenReady(loadMonth);
})(); })();

View File

@@ -23,6 +23,7 @@
<div class="access-denied" id="accessDenied" hidden> <div class="access-denied" id="accessDenied" hidden>
<p>Доступ запрещён.</p> <p>Доступ запрещён.</p>
<p class="muted">Откройте календарь из Telegram.</p> <p class="muted">Откройте календарь из Telegram.</p>
<p class="debug" id="accessDeniedDebug"></p>
</div> </div>
</div> </div>
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>

View File

@@ -166,6 +166,12 @@ body {
font-weight: 600; font-weight: 600;
} }
.access-denied .debug {
font-size: 0.75rem;
margin-top: 12px;
word-break: break-all;
}
.access-denied[hidden] { .access-denied[hidden] {
display: none !important; display: none !important;
} }