diff --git a/.dockerignore b/.dockerignore index 6679676..1ebb544 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ venv/ +.venv/ .env .git/ __pycache__/ @@ -8,3 +9,4 @@ __pycache__/ .cursor/ *.plan.md data/ +.curosr/ diff --git a/.env.example b/.env.example index e026cb4..a1f44f9 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ HTTP_PORT=8080 # Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed. ALLOWED_USERNAMES=username1,username2 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 diff --git a/.gitignore b/.gitignore index 06716c7..21f47da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ .env __pycache__/ venv/ +.venv/ *.pyc *.pyo data/ *.db +.cursor/ diff --git a/README.md b/README.md index b0197b5..77fbfb3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co 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. +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 ```bash @@ -62,9 +68,24 @@ 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`, `ALLOWED_USERNAMES`, `ADMIN_USERNAMES` from env; exits if `BOT_TOKEN` is missing. +- `main.py` – Builds the `Application`, registers handlers, runs polling and FastAPI in a thread. +- `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. -- `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`. + +## 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). diff --git a/alembic/env.py b/alembic/env.py index e9d3d8c..6a2c797 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -3,22 +3,20 @@ import os import sys from logging.config import fileConfig -from dotenv import load_dotenv from sqlalchemy import create_engine from alembic import context -load_dotenv() - sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import config from db.models import Base -config = context.config -if config.config_file_name is not None: - fileConfig(config.config_file_name) +config_alembic = context.config +if config_alembic.config_file_name is not None: + fileConfig(config_alembic.config_file_name) -database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db") -config.set_main_option("sqlalchemy.url", database_url) +database_url = config.DATABASE_URL +config_alembic.set_main_option("sqlalchemy.url", database_url) target_metadata = Base.metadata diff --git a/api/app.py b/api/app.py index bc3d48e..c309250 100644 --- a/api/app.py +++ b/api/app.py @@ -1,4 +1,6 @@ """FastAPI app: /api/duties and static webapp.""" +import logging +import re from pathlib import Path import config @@ -6,15 +8,68 @@ from fastapi import FastAPI, Header, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware 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.schemas import DutyWithUser 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.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=config.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -28,47 +83,25 @@ def list_duties( 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"), ) -> 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() if not init_data: - # Allow access from localhost without Telegram initData (local dev only) client_host = request.client.host if request.client else None - if client_host in ("127.0.0.1", "::1"): - session = get_session(config.DATABASE_URL) - 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() + if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH: + if config.MINI_APP_SKIP_AUTH: + log.warning("duties: allowing without initData (MINI_APP_SKIP_AUTH is set)") + return _fetch_duties_response(from_date, to_date) + log.warning("duties: no X-Telegram-Init-Data header (client=%s)", client_host) raise HTTPException(status_code=403, detail="Откройте календарь из Telegram") username = validate_init_data(init_data, config.BOT_TOKEN) if username is None: + log.warning("duties: initData validation failed (invalid signature or no username)") raise HTTPException(status_code=403, detail="Неверные данные авторизации") if not config.can_access_miniapp(username): + log.warning("duties: username not in allowlist") 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) - 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() + return _fetch_duties_response(from_date, to_date) webapp_path = Path(__file__).resolve().parent.parent / "webapp" diff --git a/api/test_app.py b/api/test_app.py new file mode 100644 index 0000000..e00a0e4 --- /dev/null +++ b/api/test_app.py @@ -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") diff --git a/api/test_telegram_auth.py b/api/test_telegram_auth.py new file mode 100644 index 0000000..ab45442 --- /dev/null +++ b/api/test_telegram_auth.py @@ -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 diff --git a/config.py b/config.py index 937f542..d7741e2 100644 --- a/config.py +++ b/config.py @@ -22,6 +22,13 @@ ALLOWED_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_allowed.split(" _raw_admin = os.getenv("ADMIN_USERNAMES", "").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: """True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES.""" diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..5141667 --- /dev/null +++ b/conftest.py @@ -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" diff --git a/db/session.py b/db/session.py index 052a52d..cf7d9c6 100644 --- a/db/session.py +++ b/db/session.py @@ -1,4 +1,7 @@ """SQLAlchemy engine and session factory.""" +from contextlib import contextmanager +from typing import Generator + from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -8,6 +11,16 @@ _engine = 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): global _engine if _engine is None: diff --git a/handlers/commands.py b/handlers/commands.py index d42ce8a..80e6d37 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -2,7 +2,7 @@ import asyncio import config -from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup +from telegram import Update, WebAppInfo, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import CommandHandler, ContextTypes from db.session import get_session @@ -39,10 +39,9 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = "Привет! Я бот календаря дежурств. Используй /help для списка команд." if config.MINI_APP_BASE_URL: - keyboard = ReplyKeyboardMarkup( - [[KeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))]], - resize_keyboard=True, - ) + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))], + ]) await update.message.reply_text(text, reply_markup=keyboard) else: await update.message.reply_text(text) diff --git a/handlers/errors.py b/handlers/errors.py index 12b69fe..2a855a4 100644 --- a/handlers/errors.py +++ b/handlers/errors.py @@ -10,4 +10,4 @@ logger = logging.getLogger(__name__) async def error_handler(update: object, 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("Something went wrong. Please try again later.") + await update.effective_message.reply_text("Произошла ошибка. Попробуйте позже.") diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5e37342 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.0,<9.0 +pytest-asyncio>=0.24,<1.0 +httpx>=0.27,<1.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..14837d5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package. diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c2702f6 --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/webapp/app.js b/webapp/app.js index 0069fd6..ace3c9a 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -13,6 +13,8 @@ const accessDeniedEl = document.getElementById("accessDenied"); const headerEl = document.querySelector(".header"); const weekdaysEl = document.querySelector(".weekdays"); + const prevBtn = document.getElementById("prevMonth"); + const nextBtn = document.getElementById("nextMonth"); function isoDate(d) { return d.toISOString().slice(0, 10); @@ -33,7 +35,33 @@ } 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() { @@ -49,6 +77,8 @@ loadingEl.classList.add("hidden"); errorEl.hidden = true; accessDeniedEl.hidden = false; + var debugEl = document.getElementById("accessDeniedDebug"); + if (debugEl) debugEl.textContent = getInitDataDebug(); } function hideAccessDenied() { @@ -65,12 +95,23 @@ 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"); + var controller = new AbortController(); + var timeoutId = setTimeout(function () { controller.abort(); }, 15000); + 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) { @@ -92,7 +133,7 @@ cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : ""); cell.innerHTML = "" + d.getDate() + "" + - (dayDuties.length ? "" + dayDuties.map(function (x) { return x.full_name; }).join(", ") + "" : ""); + (dayDuties.length ? "" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "" : ""); calendarEl.appendChild(cell); d.setDate(d.getDate() + 1); } @@ -151,13 +192,28 @@ loadingEl.classList.add("hidden"); } - async function loadMonth() { - var _initData = getInitData(); - if (!_initData && !isLocalhost()) { - showAccessDenied(); - return; + function runWhenReady(cb) { + if (window.Telegram && window.Telegram.WebApp) { + if (window.Telegram.WebApp.ready) { + window.Telegram.WebApp.ready(); + } + 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(); + setNavEnabled(false); loadingEl.classList.remove("hidden"); errorEl.hidden = true; const from = isoDate(firstDayOfMonth(current)); @@ -170,12 +226,19 @@ } catch (e) { if (e.message === "ACCESS_DENIED") { showAccessDenied(); + setNavEnabled(true); + if (window.Telegram && window.Telegram.WebApp && !window._initDataRetried) { + window._initDataRetried = true; + setTimeout(loadMonth, 1200); + } return; } showError(e.message || "Не удалось загрузить данные."); + setNavEnabled(true); return; } loadingEl.classList.add("hidden"); + setNavEnabled(true); } document.getElementById("prevMonth").addEventListener("click", function () { @@ -187,5 +250,5 @@ loadMonth(); }); - loadMonth(); + runWhenReady(loadMonth); })(); diff --git a/webapp/index.html b/webapp/index.html index 1df6f62..6bdda3a 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -23,6 +23,7 @@ diff --git a/webapp/style.css b/webapp/style.css index 1918c20..de429af 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -166,6 +166,12 @@ body { font-weight: 600; } +.access-denied .debug { + font-size: 0.75rem; + margin-top: 12px; + word-break: break-all; +} + .access-denied[hidden] { display: none !important; }