diff --git a/alembic/env.py b/alembic/env.py index 7903f04..eb3640a 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from app.core.config import settings from app.db.base import Base -from app.models import car, expense, user # noqa: F401 +from app.models import car, expense, push, user # noqa: F401 config = context.config config.set_main_option("sqlalchemy.url", settings.database_url) diff --git a/alembic/versions/202605120001_push_subscriptions.py b/alembic/versions/202605120001_push_subscriptions.py new file mode 100644 index 0000000..6e616fa --- /dev/null +++ b/alembic/versions/202605120001_push_subscriptions.py @@ -0,0 +1,39 @@ +"""push subscriptions + +Revision ID: 202605120001 +Revises: 202605110003 +Create Date: 2026-05-12 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "202605120001" +down_revision: str | None = "202605110003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "push_subscriptions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("endpoint", sa.Text(), nullable=False), + sa.Column("p256dh", sa.String(length=256), nullable=True), + sa.Column("auth", sa.String(length=256), nullable=True), + sa.Column("user_agent", sa.String(length=256), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "endpoint", name="uq_push_user_endpoint"), + ) + op.create_index(op.f("ix_push_subscriptions_user_id"), "push_subscriptions", ["user_id"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_push_subscriptions_user_id"), table_name="push_subscriptions") + op.drop_table("push_subscriptions") diff --git a/app/api/users.py b/app/api/users.py index a6076e7..9964083 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -1,29 +1,110 @@ -from fastapi import APIRouter, Depends, HTTPException +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import settings from app.db.session import get_session +from app.models.car import Car +from app.models.expense import ServiceEntry +from app.models.push import PushSubscription from app.models.user import User -from app.schemas.user import UserPreferencesUpdate, UserRead, UserUpsert +from app.schemas.user import ( + AuthConfig, + PushSubscriptionCreate, + ReminderRead, + TelegramLoginRequest, + UserPreferencesUpdate, + UserRead, + UserUpsert, + WebAppAuthRequest, +) +from app.services.telegram_auth import verify_login_widget, verify_webapp_init_data router = APIRouter(prefix="/users", tags=["users"]) -@router.post("", response_model=UserRead) -async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User: - result = await session.execute(select(User).where(User.telegram_id == payload.telegram_id)) +def username_from_telegram(telegram_id: int, username: str | None = None) -> str: + return str(telegram_id) if not username else str(telegram_id) + + +async def upsert_telegram_user( + session: AsyncSession, + *, + telegram_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + locale: str | None = None, + currency: str | None = None, +) -> User: + result = await session.execute(select(User).where(User.telegram_id == telegram_id)) user = result.scalar_one_or_none() + payload = { + "telegram_id": telegram_id, + "username": username_from_telegram(telegram_id, username), + "first_name": first_name, + "last_name": last_name, + "locale": locale, + "currency": currency, + } if user is None: - user = User(**payload.model_dump(exclude_none=True)) + user = User(**{key: value for key, value in payload.items() if value is not None}) session.add(user) else: - for field, value in payload.model_dump(exclude_none=True).items(): - setattr(user, field, value) + for field, value in payload.items(): + if value is not None: + setattr(user, field, value) await session.commit() await session.refresh(user) return user +@router.post("", response_model=UserRead) +async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User: + return await upsert_telegram_user(session, **payload.model_dump()) + + +@router.get("/auth/config", response_model=AuthConfig) +async def auth_config() -> AuthConfig: + return AuthConfig( + bot_username=settings.bot_username or "seoulmate_officialbot", + vapid_public_key=settings.vapid_public_key or None, + ) + + +@router.post("/webapp-auth", response_model=UserRead) +async def webapp_auth( + payload: WebAppAuthRequest, session: AsyncSession = Depends(get_session) +) -> User: + user_data = verify_webapp_init_data(payload.init_data, settings.bot_token) + telegram_id = int(user_data["id"]) + return await upsert_telegram_user( + session, + telegram_id=telegram_id, + username=user_data.get("username"), + first_name=user_data.get("first_name"), + last_name=user_data.get("last_name"), + locale=user_data.get("language_code"), + ) + + +@router.post("/telegram-login", response_model=UserRead) +async def telegram_login( + payload: TelegramLoginRequest, session: AsyncSession = Depends(get_session) +) -> User: + values = verify_login_widget(payload.model_dump(), settings.bot_token) + telegram_id = int(values["id"]) + return await upsert_telegram_user( + session, + telegram_id=telegram_id, + username=values.get("username"), + first_name=values.get("first_name"), + last_name=values.get("last_name"), + ) + + @router.get("/telegram/{telegram_id}", response_model=UserRead) async def get_user_by_telegram_id( telegram_id: int, session: AsyncSession = Depends(get_session) @@ -44,3 +125,85 @@ async def update_preferences( await session.commit() await session.refresh(user) return user + + +@router.post("/{user_id}/push-subscriptions", status_code=status.HTTP_204_NO_CONTENT) +async def save_push_subscription( + user_id: int, + payload: PushSubscriptionCreate, + request: Request, + session: AsyncSession = Depends(get_session), +) -> None: + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + result = await session.execute( + select(PushSubscription).where( + PushSubscription.user_id == user_id, + PushSubscription.endpoint == payload.endpoint, + ) + ) + subscription = result.scalar_one_or_none() + keys = payload.keys or None + user_agent = payload.user_agent or request.headers.get("user-agent") + if subscription is None: + subscription = PushSubscription( + user_id=user_id, + endpoint=payload.endpoint, + p256dh=keys.p256dh if keys else None, + auth=keys.auth if keys else None, + user_agent=user_agent[:256] if user_agent else None, + ) + session.add(subscription) + else: + subscription.p256dh = keys.p256dh if keys else subscription.p256dh + subscription.auth = keys.auth if keys else subscription.auth + subscription.user_agent = user_agent[:256] if user_agent else subscription.user_agent + await session.commit() + + +@router.get("/{user_id}/reminders", response_model=list[ReminderRead]) +async def due_reminders( + user_id: int, + days: int = 30, + session: AsyncSession = Depends(get_session), +) -> list[ReminderRead]: + today = date.today() + horizon = today + timedelta(days=max(1, min(days, 180))) + stmt = ( + select(ServiceEntry, Car) + .join(Car, ServiceEntry.car_id == Car.id) + .where(Car.owner_id == user_id) + .where( + (ServiceEntry.next_due_date.is_not(None) & (ServiceEntry.next_due_date <= horizon)) + | ( + ServiceEntry.next_due_odometer.is_not(None) + & Car.current_odometer.is_not(None) + & (ServiceEntry.next_due_odometer <= Car.current_odometer + 1000) + ) + ) + .order_by(ServiceEntry.next_due_date.asc().nulls_last()) + ) + rows = (await session.execute(stmt)).all() + reminders: list[ReminderRead] = [] + for service, car in rows: + overdue_date = service.next_due_date is not None and service.next_due_date <= today + overdue_odo = ( + service.next_due_odometer is not None + and car.current_odometer is not None + and service.next_due_odometer <= car.current_odometer + ) + reminders.append( + ReminderRead( + id=service.id, + car_id=car.id, + car_name=car.name, + title=service.title, + service_type=service.service_type.value, + due_date=service.next_due_date.isoformat() if service.next_due_date else None, + due_odometer=service.next_due_odometer, + current_odometer=car.current_odometer, + priority="overdue" if overdue_date or overdue_odo else "soon", + ) + ) + return reminders diff --git a/app/core/config.py b/app/core/config.py index 29842f9..eb24eb3 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,10 +6,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): database_url: str = "postgresql+asyncpg://drivers:drivers@localhost:5432/drivers" bot_token: str = "" + bot_username: str = "" api_base_url: str = "http://localhost:8000" webapp_url: str = "http://localhost:8000" app_host: str = "0.0.0.0" app_port: int = 8000 + app_env: str = "production" + vapid_public_key: str = "" model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/app/models/__init__.py b/app/models/__init__.py index 8b13789..44f4c6d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1 +1,3 @@ +from app.models.push import PushSubscription +__all__ = ["PushSubscription"] diff --git a/app/models/push.py b/app/models/push.py new file mode 100644 index 0000000..a556f72 --- /dev/null +++ b/app/models/push.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class PushSubscription(Base): + __tablename__ = "push_subscriptions" + __table_args__ = (UniqueConstraint("user_id", "endpoint", name="uq_push_user_endpoint"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + endpoint: Mapped[str] = mapped_column(Text) + p256dh: Mapped[str | None] = mapped_column(String(256)) + auth: Mapped[str | None] = mapped_column(String(256)) + user_agent: Mapped[str | None] = mapped_column(String(256)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + user = relationship("User", back_populates="push_subscriptions") diff --git a/app/models/user.py b/app/models/user.py index bf7c351..3130e27 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -22,3 +22,6 @@ class User(Base): ) cars = relationship("Car", back_populates="owner", cascade="all, delete-orphan") + push_subscriptions = relationship( + "PushSubscription", back_populates="user", cascade="all, delete-orphan" + ) diff --git a/app/schemas/user.py b/app/schemas/user.py index 51d764c..8811d98 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -12,6 +12,48 @@ class UserUpsert(BaseModel): currency: str | None = None +class WebAppAuthRequest(BaseModel): + init_data: str + + +class TelegramLoginRequest(BaseModel): + id: int + first_name: str | None = None + last_name: str | None = None + username: str | None = None + photo_url: str | None = None + auth_date: int + hash: str + + +class AuthConfig(BaseModel): + bot_username: str + vapid_public_key: str | None = None + + +class PushSubscriptionKeys(BaseModel): + p256dh: str | None = None + auth: str | None = None + + +class PushSubscriptionCreate(BaseModel): + endpoint: str + keys: PushSubscriptionKeys | None = None + user_agent: str | None = None + + +class ReminderRead(BaseModel): + id: int + car_id: int + car_name: str + title: str + service_type: str + due_date: str | None = None + due_odometer: int | None = None + current_odometer: int | None = None + priority: str + + class UserPreferencesUpdate(BaseModel): locale: str | None = None currency: str | None = None diff --git a/app/services/telegram_auth.py b/app/services/telegram_auth.py new file mode 100644 index 0000000..daa67ba --- /dev/null +++ b/app/services/telegram_auth.py @@ -0,0 +1,49 @@ +import hashlib +import hmac +import json +import time +from urllib.parse import parse_qsl + +from fastapi import HTTPException, status + + +def _secret_key(bot_token: str, *, webapp: bool) -> bytes: + if webapp: + return hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest() + return hashlib.sha256(bot_token.encode()).digest() + + +def verify_webapp_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict: + values = dict(parse_qsl(init_data, keep_blank_values=True)) + received_hash = values.pop("hash", "") + if not received_hash: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram hash missing") + auth_date = int(values.get("auth_date") or 0) + if max_age_seconds and auth_date and time.time() - auth_date > max_age_seconds: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth expired") + + data_check_string = "\n".join(f"{key}={values[key]}" for key in sorted(values)) + expected = hmac.new(_secret_key(bot_token, webapp=True), data_check_string.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, received_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth invalid") + + user_raw = values.get("user") + if not user_raw: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram user missing") + return json.loads(user_raw) + + +def verify_login_widget(payload: dict, bot_token: str, max_age_seconds: int = 86400) -> dict: + values = {key: value for key, value in payload.items() if value is not None} + received_hash = str(values.pop("hash", "")) + if not received_hash: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram hash missing") + auth_date = int(values.get("auth_date") or 0) + if max_age_seconds and auth_date and time.time() - auth_date > max_age_seconds: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth expired") + + data_check_string = "\n".join(f"{key}={values[key]}" for key in sorted(values)) + expected = hmac.new(_secret_key(bot_token, webapp=False), data_check_string.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, received_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth invalid") + return values diff --git a/web/index.html b/web/index.html index a6d873e..d6c7958 100644 --- a/web/index.html +++ b/web/index.html @@ -12,6 +12,14 @@ +
+
+

Drivers

+

Гараж

+

Войди через Telegram, чтобы привязать гараж к твоему chat_id.

+ +
+
@@ -187,6 +195,14 @@ Исполнитель + + @@ -235,24 +251,6 @@ - -

Новое авто

@@ -278,6 +276,26 @@
+ +