add telegram auth and reminders foundation

This commit is contained in:
VPN SaaS Dev
2026-05-12 04:36:30 +09:00
parent a6cdc98f7b
commit f7a3b8be54
13 changed files with 522 additions and 52 deletions

View File

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

View File

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

View File

@@ -1 +1,3 @@
from app.models.push import PushSubscription
__all__ = ["PushSubscription"]

24
app/models/push.py Normal file
View File

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

View File

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

View File

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

View File

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