Add standalone FastAPI server backend (Phase 3)
Some checks failed
Validate / Hassfest (push) Has been cancelled

Build a complete standalone web server for Immich album change
notifications, independent of Home Assistant. Uses the shared
core library from Phase 1.

Server features:
- FastAPI with async SQLite (SQLModel + aiosqlite)
- Multi-user auth with JWT (admin/user roles, setup wizard)
- CRUD APIs: Immich servers, album trackers, message templates,
  notification targets (Telegram + webhook), user management
- APScheduler background polling per tracker
- Jinja2 template rendering with live preview
- Album browser proxied from Immich API
- Event logging and dashboard status endpoint
- Docker deployment (single container, SQLite in volume)

39 API routes, 14 integration tests passing.

Also adds Phase 6 (Claude AI Telegram bot enhancement) to the
primary plan as an optional future phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:56:22 +03:00
parent b107cfe67f
commit 58b2281dc6
28 changed files with 1982 additions and 1 deletions

View File

@@ -0,0 +1 @@
"""Authentication package."""

View File

@@ -0,0 +1,52 @@
"""FastAPI dependencies for authentication."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import jwt
from ..database.engine import get_session
from ..database.models import User
from .jwt import decode_token
_bearer = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
"""Extract and validate the current user from the JWT token."""
try:
payload = decode_token(credentials.credentials)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
)
user_id = int(payload["sub"])
except (jwt.PyJWTError, KeyError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
) from exc
user = await session.get(User, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""Require the current user to be an admin."""
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user

View File

@@ -0,0 +1,44 @@
"""JWT token creation and validation."""
from datetime import datetime, timedelta, timezone
import jwt
from ..config import settings
ALGORITHM = "HS256"
def create_access_token(user_id: int, role: str) -> str:
"""Create a short-lived access token."""
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {
"sub": str(user_id),
"role": role,
"type": "access",
"exp": expire,
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def create_refresh_token(user_id: int) -> str:
"""Create a long-lived refresh token."""
expire = datetime.now(timezone.utc) + timedelta(
days=settings.refresh_token_expire_days
)
payload = {
"sub": str(user_id),
"type": "refresh",
"exp": expire,
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
"""Decode and validate a JWT token.
Raises jwt.PyJWTError on invalid/expired tokens.
"""
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])

View File

@@ -0,0 +1,138 @@
"""Authentication API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
import bcrypt
from ..database.engine import get_session
from ..database.models import User
from .dependencies import get_current_user
from .jwt import create_access_token, create_refresh_token, decode_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
class SetupRequest(BaseModel):
username: str
password: str
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: int
username: str
role: str
class RefreshRequest(BaseModel):
refresh_token: str
def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def _verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
@router.post("/setup", response_model=TokenResponse)
async def setup(
body: SetupRequest,
session: AsyncSession = Depends(get_session),
):
"""Create the first admin user. Only works when no users exist."""
result = await session.exec(select(func.count()).select_from(User))
count = result.one()
if count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Setup already completed. Use /api/auth/login instead.",
)
user = User(
username=body.username,
hashed_password=_hash_password(body.password),
role="admin",
)
session.add(user)
await session.commit()
await session.refresh(user)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.post("/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
session: AsyncSession = Depends(get_session),
):
"""Authenticate and return JWT tokens."""
result = await session.exec(select(User).where(User.username == body.username))
user = result.first()
if not user or not _verify_password(body.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
body: RefreshRequest,
session: AsyncSession = Depends(get_session),
):
"""Refresh access token using a refresh token."""
import jwt as pyjwt
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = int(payload["sub"])
except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.get("/me", response_model=UserResponse)
async def me(user: User = Depends(get_current_user)):
"""Get current user info."""
return UserResponse(id=user.id, username=user.username, role=user.role)
@router.get("/needs-setup")
async def needs_setup(session: AsyncSession = Depends(get_session)):
"""Check if initial setup is needed (no users exist)."""
result = await session.exec(select(func.count()).select_from(User))
count = result.one()
return {"needs_setup": count == 0}