From 7f99c895a481a838d6621c36229cf17c2477f882 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 23:39:23 +0300 Subject: [PATCH] feat(notify-bridge): phase 6 - database models and server API New database schema with ServiceProvider abstraction: - ServiceProvider (replaces ImmichServer): type + JSON config - Tracker (replaces AlbumTracker): owns tracking_config_id - TrackingConfig: provider_type scoped, owned by Tracker - TemplateConfig: provider_type scoped, owned by Target - NotificationTarget: owns template_config_id (not tracking_config_id) - TrackerState, EventLog, User, TelegramBot, TelegramChat Full FastAPI server: - /api/providers: CRUD + test connection + list collections - /api/trackers: CRUD - /api/tracking-configs: CRUD with provider_type filter - /api/template-configs: CRUD with provider_type filter, system defaults - /api/targets: CRUD - /api/template-vars: variable docs filtered by provider type - /api/auth: setup, login, refresh, me, password change - /api/health: health check - Default template seeding on first startup (EN/RU for Immich) - pydantic-settings with NOTIFY_BRIDGE_ env prefix Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/server/pyproject.toml | 1 + .../src/notify_bridge_server/api/providers.py | 159 +++++++++++++ .../src/notify_bridge_server/api/targets.py | 97 ++++++++ .../api/template_configs.py | 85 +++++++ .../notify_bridge_server/api/template_vars.py | 33 +++ .../src/notify_bridge_server/api/trackers.py | 104 +++++++++ .../api/tracking_configs.py | 83 +++++++ .../notify_bridge_server/auth/dependencies.py | 37 +++ .../src/notify_bridge_server/auth/jwt.py | 25 ++ .../src/notify_bridge_server/auth/routes.py | 134 +++++++++++ .../server/src/notify_bridge_server/config.py | 51 +++- .../notify_bridge_server/database/engine.py | 33 +++ .../notify_bridge_server/database/models.py | 219 ++++++++++++++++++ .../server/src/notify_bridge_server/main.py | 64 ++++- 14 files changed, 1116 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/notify_bridge_server/api/providers.py create mode 100644 packages/server/src/notify_bridge_server/api/targets.py create mode 100644 packages/server/src/notify_bridge_server/api/template_configs.py create mode 100644 packages/server/src/notify_bridge_server/api/template_vars.py create mode 100644 packages/server/src/notify_bridge_server/api/trackers.py create mode 100644 packages/server/src/notify_bridge_server/api/tracking_configs.py create mode 100644 packages/server/src/notify_bridge_server/auth/dependencies.py create mode 100644 packages/server/src/notify_bridge_server/auth/jwt.py create mode 100644 packages/server/src/notify_bridge_server/auth/routes.py create mode 100644 packages/server/src/notify_bridge_server/database/engine.py create mode 100644 packages/server/src/notify_bridge_server/database/models.py diff --git a/packages/server/pyproject.toml b/packages/server/pyproject.toml index 98f4eae..5df2fa2 100644 --- a/packages/server/pyproject.toml +++ b/packages/server/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "bcrypt>=4.2", "apscheduler>=3.10,<4", "aiohttp>=3.9", + "pydantic-settings>=2.0", "anthropic>=0.42", ] diff --git a/packages/server/src/notify_bridge_server/api/providers.py b/packages/server/src/notify_bridge_server/api/providers.py new file mode 100644 index 0000000..f125c2e --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/providers.py @@ -0,0 +1,159 @@ +"""Service Provider CRUD API routes.""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession +from typing import Any + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import ServiceProvider, User + +router = APIRouter(prefix="/api/providers", tags=["providers"]) + + +class ProviderCreate(BaseModel): + type: str + name: str + icon: str = "" + config: dict[str, Any] = {} + + +class ProviderUpdate(BaseModel): + name: str | None = None + icon: str | None = None + config: dict[str, Any] | None = None + + +@router.get("") +async def list_providers( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + result = await session.exec( + select(ServiceProvider).where(ServiceProvider.user_id == user.id) + ) + return result.all() + + +@router.post("", status_code=201) +async def create_provider( + body: ProviderCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + provider = ServiceProvider( + user_id=user.id, + type=body.type, + name=body.name, + icon=body.icon, + config=body.config, + ) + session.add(provider) + await session.commit() + await session.refresh(provider) + return provider + + +@router.get("/{provider_id}") +async def get_provider( + provider_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + provider = await session.get(ServiceProvider, provider_id) + if not provider or provider.user_id != user.id: + raise HTTPException(status_code=404, detail="Provider not found") + return provider + + +@router.put("/{provider_id}") +async def update_provider( + provider_id: int, + body: ProviderUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + provider = await session.get(ServiceProvider, provider_id) + if not provider or provider.user_id != user.id: + raise HTTPException(status_code=404, detail="Provider not found") + + if body.name is not None: + provider.name = body.name + if body.icon is not None: + provider.icon = body.icon + if body.config is not None: + provider.config = body.config + + session.add(provider) + await session.commit() + await session.refresh(provider) + return provider + + +@router.delete("/{provider_id}", status_code=204) +async def delete_provider( + provider_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + provider = await session.get(ServiceProvider, provider_id) + if not provider or provider.user_id != user.id: + raise HTTPException(status_code=404, detail="Provider not found") + await session.delete(provider) + await session.commit() + + +@router.post("/{provider_id}/test") +async def test_provider( + provider_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + provider = await session.get(ServiceProvider, provider_id) + if not provider or provider.user_id != user.id: + raise HTTPException(status_code=404, detail="Provider not found") + + if provider.type == "immich": + import aiohttp + from notify_bridge_core.providers.immich import ImmichServiceProvider + config = provider.config + async with aiohttp.ClientSession() as http_session: + immich = ImmichServiceProvider( + http_session, + config.get("url", ""), + config.get("api_key", ""), + config.get("external_domain"), + provider.name, + ) + return await immich.test_connection() + + return {"ok": False, "message": f"Unknown provider type: {provider.type}"} + + +@router.get("/{provider_id}/collections") +async def list_collections( + provider_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + provider = await session.get(ServiceProvider, provider_id) + if not provider or provider.user_id != user.id: + raise HTTPException(status_code=404, detail="Provider not found") + + if provider.type == "immich": + import aiohttp + from notify_bridge_core.providers.immich import ImmichServiceProvider + config = provider.config + async with aiohttp.ClientSession() as http_session: + immich = ImmichServiceProvider( + http_session, + config.get("url", ""), + config.get("api_key", ""), + config.get("external_domain"), + provider.name, + ) + return await immich.list_collections() + + return [] diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py new file mode 100644 index 0000000..14c4a00 --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/targets.py @@ -0,0 +1,97 @@ +"""NotificationTarget CRUD API routes.""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession +from typing import Any + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import NotificationTarget, User + +router = APIRouter(prefix="/api/targets", tags=["targets"]) + + +class TargetCreate(BaseModel): + type: str + name: str + icon: str = "" + config: dict[str, Any] = {} + template_config_id: int | None = None + + +class TargetUpdate(BaseModel): + name: str | None = None + icon: str | None = None + config: dict[str, Any] | None = None + template_config_id: int | None = None + + +@router.get("") +async def list_targets( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + result = await session.exec( + select(NotificationTarget).where(NotificationTarget.user_id == user.id) + ) + return result.all() + + +@router.post("", status_code=201) +async def create_target( + body: TargetCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + target = NotificationTarget(user_id=user.id, **body.model_dump()) + session.add(target) + await session.commit() + await session.refresh(target) + return target + + +@router.get("/{target_id}") +async def get_target( + target_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + target = await session.get(NotificationTarget, target_id) + if not target or target.user_id != user.id: + raise HTTPException(status_code=404, detail="Target not found") + return target + + +@router.put("/{target_id}") +async def update_target( + target_id: int, + body: TargetUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + target = await session.get(NotificationTarget, target_id) + if not target or target.user_id != user.id: + raise HTTPException(status_code=404, detail="Target not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(target, field, value) + + session.add(target) + await session.commit() + await session.refresh(target) + return target + + +@router.delete("/{target_id}", status_code=204) +async def delete_target( + target_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + target = await session.get(NotificationTarget, target_id) + if not target or target.user_id != user.id: + raise HTTPException(status_code=404, detail="Target not found") + await session.delete(target) + await session.commit() diff --git a/packages/server/src/notify_bridge_server/api/template_configs.py b/packages/server/src/notify_bridge_server/api/template_configs.py new file mode 100644 index 0000000..c9d93d1 --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/template_configs.py @@ -0,0 +1,85 @@ +"""TemplateConfig CRUD API routes.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import TemplateConfig, User + +router = APIRouter(prefix="/api/template-configs", tags=["template-configs"]) + + +@router.get("") +async def list_template_configs( + provider_type: str | None = None, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + query = select(TemplateConfig).where( + (TemplateConfig.user_id == user.id) | (TemplateConfig.user_id == 0) + ) + if provider_type: + query = query.where(TemplateConfig.provider_type == provider_type) + result = await session.exec(query) + return result.all() + + +@router.post("", status_code=201) +async def create_template_config( + body: dict, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = TemplateConfig(user_id=user.id, **body) + session.add(config) + await session.commit() + await session.refresh(config) + return config + + +@router.get("/{config_id}") +async def get_template_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await session.get(TemplateConfig, config_id) + if not config or (config.user_id != user.id and config.user_id != 0): + raise HTTPException(status_code=404, detail="Template config not found") + return config + + +@router.put("/{config_id}") +async def update_template_config( + config_id: int, + body: dict, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await session.get(TemplateConfig, config_id) + if not config or config.user_id != user.id: + raise HTTPException(status_code=404, detail="Template config not found") + + for field, value in body.items(): + if field not in ("id", "user_id", "created_at"): + setattr(config, field, value) + + session.add(config) + await session.commit() + await session.refresh(config) + return config + + +@router.delete("/{config_id}", status_code=204) +async def delete_template_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await session.get(TemplateConfig, config_id) + if not config or config.user_id != user.id: + raise HTTPException(status_code=404, detail="Template config not found") + await session.delete(config) + await session.commit() diff --git a/packages/server/src/notify_bridge_server/api/template_vars.py b/packages/server/src/notify_bridge_server/api/template_vars.py new file mode 100644 index 0000000..3b26ab1 --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/template_vars.py @@ -0,0 +1,33 @@ +"""Template variable documentation endpoint.""" + +from fastapi import APIRouter + +from notify_bridge_core.providers.base import ServiceProviderType +from notify_bridge_core.providers.immich import ImmichServiceProvider # noqa: F401 — triggers registration +from notify_bridge_core.templates.variables import registry + +router = APIRouter(prefix="/api/template-vars", tags=["template-vars"]) + + +@router.get("") +async def get_template_variables(provider_type: str | None = None): + """Get available template variables, optionally filtered by provider type.""" + if provider_type: + try: + pt = ServiceProviderType(provider_type) + except ValueError: + return {"error": f"Unknown provider type: {provider_type}"} + variables = registry.get_variables(pt) + else: + variables = registry.get_base_variables() + + return [ + { + "name": v.name, + "type": v.type, + "description": v.description, + "example": v.example, + "provider_type": v.provider_type.value if v.provider_type else None, + } + for v in variables + ] diff --git a/packages/server/src/notify_bridge_server/api/trackers.py b/packages/server/src/notify_bridge_server/api/trackers.py new file mode 100644 index 0000000..a32f9cc --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/trackers.py @@ -0,0 +1,104 @@ +"""Tracker CRUD API routes.""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import Tracker, User + +router = APIRouter(prefix="/api/trackers", tags=["trackers"]) + + +class TrackerCreate(BaseModel): + provider_id: int + name: str + icon: str = "" + collection_ids: list[str] = [] + target_ids: list[int] = [] + tracking_config_id: int | None = None + scan_interval: int = 60 + enabled: bool = True + quiet_hours_start: str | None = None + quiet_hours_end: str | None = None + + +class TrackerUpdate(BaseModel): + name: str | None = None + icon: str | None = None + collection_ids: list[str] | None = None + target_ids: list[int] | None = None + tracking_config_id: int | None = None + scan_interval: int | None = None + enabled: bool | None = None + quiet_hours_start: str | None = None + quiet_hours_end: str | None = None + + +@router.get("") +async def list_trackers( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + result = await session.exec(select(Tracker).where(Tracker.user_id == user.id)) + return result.all() + + +@router.post("", status_code=201) +async def create_tracker( + body: TrackerCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + tracker = Tracker(user_id=user.id, **body.model_dump()) + session.add(tracker) + await session.commit() + await session.refresh(tracker) + return tracker + + +@router.get("/{tracker_id}") +async def get_tracker( + tracker_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + tracker = await session.get(Tracker, tracker_id) + if not tracker or tracker.user_id != user.id: + raise HTTPException(status_code=404, detail="Tracker not found") + return tracker + + +@router.put("/{tracker_id}") +async def update_tracker( + tracker_id: int, + body: TrackerUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + tracker = await session.get(Tracker, tracker_id) + if not tracker or tracker.user_id != user.id: + raise HTTPException(status_code=404, detail="Tracker not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(tracker, field, value) + + session.add(tracker) + await session.commit() + await session.refresh(tracker) + return tracker + + +@router.delete("/{tracker_id}", status_code=204) +async def delete_tracker( + tracker_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + tracker = await session.get(Tracker, tracker_id) + if not tracker or tracker.user_id != user.id: + raise HTTPException(status_code=404, detail="Tracker not found") + await session.delete(tracker) + await session.commit() diff --git a/packages/server/src/notify_bridge_server/api/tracking_configs.py b/packages/server/src/notify_bridge_server/api/tracking_configs.py new file mode 100644 index 0000000..64d7c3c --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/tracking_configs.py @@ -0,0 +1,83 @@ +"""TrackingConfig CRUD API routes.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import TrackingConfig, User + +router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"]) + + +@router.get("") +async def list_tracking_configs( + provider_type: str | None = None, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + query = select(TrackingConfig).where(TrackingConfig.user_id == user.id) + if provider_type: + query = query.where(TrackingConfig.provider_type == provider_type) + result = await session.exec(query) + return result.all() + + +@router.post("", status_code=201) +async def create_tracking_config( + body: dict, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = TrackingConfig(user_id=user.id, **body) + session.add(config) + await session.commit() + await session.refresh(config) + return config + + +@router.get("/{config_id}") +async def get_tracking_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await session.get(TrackingConfig, config_id) + if not config or config.user_id != user.id: + raise HTTPException(status_code=404, detail="Tracking config not found") + return config + + +@router.put("/{config_id}") +async def update_tracking_config( + config_id: int, + body: dict, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await session.get(TrackingConfig, config_id) + if not config or config.user_id != user.id: + raise HTTPException(status_code=404, detail="Tracking config not found") + + for field, value in body.items(): + if field not in ("id", "user_id", "created_at"): + setattr(config, field, value) + + session.add(config) + await session.commit() + await session.refresh(config) + return config + + +@router.delete("/{config_id}", status_code=204) +async def delete_tracking_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await session.get(TrackingConfig, config_id) + if not config or config.user_id != user.id: + raise HTTPException(status_code=404, detail="Tracking config not found") + await session.delete(config) + await session.commit() diff --git a/packages/server/src/notify_bridge_server/auth/dependencies.py b/packages/server/src/notify_bridge_server/auth/dependencies.py new file mode 100644 index 0000000..c8fcb0d --- /dev/null +++ b/packages/server/src/notify_bridge_server/auth/dependencies.py @@ -0,0 +1,37 @@ +"""FastAPI dependencies for authentication.""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +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: + 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: + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user diff --git a/packages/server/src/notify_bridge_server/auth/jwt.py b/packages/server/src/notify_bridge_server/auth/jwt.py new file mode 100644 index 0000000..4371818 --- /dev/null +++ b/packages/server/src/notify_bridge_server/auth/jwt.py @@ -0,0 +1,25 @@ +"""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: + 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: + 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: + return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) diff --git a/packages/server/src/notify_bridge_server/auth/routes.py b/packages/server/src/notify_bridge_server/auth/routes.py new file mode 100644 index 0000000..74e66bd --- /dev/null +++ b/packages/server/src/notify_bridge_server/auth/routes.py @@ -0,0 +1,134 @@ +"""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)): + 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.") + + 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)): + 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)): + 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)): + return UserResponse(id=user.id, username=user.username, role=user.role) + + +class PasswordChangeRequest(BaseModel): + current_password: str + new_password: str + + +@router.put("/password") +async def change_password( + body: PasswordChangeRequest, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + if not _verify_password(body.current_password, user.hashed_password): + raise HTTPException(status_code=400, detail="Current password is incorrect") + if len(body.new_password) < 6: + raise HTTPException(status_code=400, detail="New password must be at least 6 characters") + user.hashed_password = _hash_password(body.new_password) + session.add(user) + await session.commit() + return {"success": True} + + +@router.get("/needs-setup") +async def needs_setup(session: AsyncSession = Depends(get_session)): + result = await session.exec(select(func.count()).select_from(User)) + count = result.one() + return {"needs_setup": count == 0} diff --git a/packages/server/src/notify_bridge_server/config.py b/packages/server/src/notify_bridge_server/config.py index 7293209..c83c3f9 100644 --- a/packages/server/src/notify_bridge_server/config.py +++ b/packages/server/src/notify_bridge_server/config.py @@ -1,12 +1,47 @@ -"""Server configuration — settings, data directory, secrets.""" +"""Server configuration from environment variables.""" -import os from pathlib import Path +from typing import Any +from pydantic_settings import BaseSettings -DATA_DIR = Path(os.environ.get("NOTIFY_BRIDGE_DATA_DIR", "./data")) -SECRET_KEY = os.environ.get("NOTIFY_BRIDGE_SECRET_KEY", "") -DATABASE_URL = os.environ.get( - "NOTIFY_BRIDGE_DATABASE_URL", - f"sqlite+aiosqlite:///{DATA_DIR / 'notify_bridge.db'}", -) +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + data_dir: Path = Path("/data") + database_url: str = "" + + secret_key: str = "change-me-in-production" + + def model_post_init(self, __context: Any) -> None: + if self.secret_key == "change-me-in-production" and not self.debug: + import logging + logging.getLogger(__name__).critical( + "SECURITY: Using default secret_key! " + "Set NOTIFY_BRIDGE_SECRET_KEY environment variable." + ) + + access_token_expire_minutes: int = 60 + refresh_token_expire_days: int = 30 + + host: str = "0.0.0.0" + port: int = 8420 + debug: bool = False + + anthropic_api_key: str = "" + ai_model: str = "claude-sonnet-4-20250514" + ai_max_tokens: int = 1024 + + telegram_webhook_secret: str = "" + + model_config = {"env_prefix": "NOTIFY_BRIDGE_"} + + @property + def effective_database_url(self) -> str: + if self.database_url: + return self.database_url + db_path = self.data_dir / "notify_bridge.db" + return f"sqlite+aiosqlite:///{db_path}" + + +settings = Settings() diff --git a/packages/server/src/notify_bridge_server/database/engine.py b/packages/server/src/notify_bridge_server/database/engine.py new file mode 100644 index 0000000..2712f99 --- /dev/null +++ b/packages/server/src/notify_bridge_server/database/engine.py @@ -0,0 +1,33 @@ +"""Database engine and session management.""" + +from collections.abc import AsyncGenerator + +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from ..config import settings + +_engine: AsyncEngine | None = None + + +def get_engine() -> AsyncEngine: + global _engine + if _engine is None: + _engine = create_async_engine( + settings.effective_database_url, + echo=settings.debug, + ) + return _engine + + +async def init_db() -> None: + engine = get_engine() + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + engine = get_engine() + async with AsyncSession(engine) as session: + yield session diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py new file mode 100644 index 0000000..7fe20b7 --- /dev/null +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -0,0 +1,219 @@ +"""SQLModel database table definitions for Notify Bridge.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlmodel import JSON, Column, Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class User(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + username: str = Field(index=True, unique=True) + hashed_password: str + role: str = Field(default="user") + created_at: datetime = Field(default_factory=_utcnow) + + +class ServiceProvider(SQLModel, table=True): + """A service provider instance (e.g., an Immich server).""" + + __tablename__ = "service_provider" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + type: str # ServiceProviderType value ("immich") + name: str + icon: str = Field(default="") + config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + created_at: datetime = Field(default_factory=_utcnow) + + +class TelegramBot(SQLModel, table=True): + __tablename__ = "telegram_bot" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + name: str + token: str + icon: str = Field(default="") + bot_username: str = Field(default="") + bot_id: int = Field(default=0) + commands_config: dict[str, Any] = Field( + default_factory=lambda: { + "enabled": ["status", "albums", "events", "summary", "latest", + "memory", "random", "search", "find", "person", + "place", "favorites", "people", "help"], + "default_count": 5, + "response_mode": "media", + "rate_limits": {"search": 30, "find": 30, "default": 10}, + "locale": "en", + }, + sa_column=Column(JSON), + ) + created_at: datetime = Field(default_factory=_utcnow) + + +class TelegramChat(SQLModel, table=True): + __tablename__ = "telegram_chat" + + id: int | None = Field(default=None, primary_key=True) + bot_id: int = Field(foreign_key="telegram_bot.id") + chat_id: str + title: str = Field(default="") + chat_type: str = Field(default="private") + username: str = Field(default="") + discovered_at: datetime = Field(default_factory=_utcnow) + + +class TrackingConfig(SQLModel, table=True): + """What events to track + scheduling rules. Tied to a provider type.""" + + __tablename__ = "tracking_config" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + provider_type: str # Must match provider's type + name: str + icon: str = Field(default="") + + # Event-driven tracking + track_assets_added: bool = Field(default=True) + track_assets_removed: bool = Field(default=False) + track_collection_renamed: bool = Field(default=True) + track_collection_deleted: bool = Field(default=True) + track_sharing_changed: bool = Field(default=False) + track_images: bool = Field(default=True) + track_videos: bool = Field(default=True) + notify_favorites_only: bool = Field(default=False) + + # Asset display + include_tags: bool = Field(default=True) + include_asset_details: bool = Field(default=False) + max_assets_to_show: int = Field(default=5) + assets_order_by: str = Field(default="none") + assets_order: str = Field(default="descending") + + # Periodic summary + periodic_enabled: bool = Field(default=False) + periodic_interval_days: int = Field(default=1) + periodic_start_date: str = Field(default="2025-01-01") + periodic_times: str = Field(default="12:00") + + # Scheduled assets + scheduled_enabled: bool = Field(default=False) + scheduled_times: str = Field(default="09:00") + scheduled_collection_mode: str = Field(default="per_collection") + scheduled_limit: int = Field(default=10) + scheduled_favorite_only: bool = Field(default=False) + scheduled_asset_type: str = Field(default="all") + scheduled_min_rating: int = Field(default=0) + scheduled_order_by: str = Field(default="random") + scheduled_order: str = Field(default="descending") + + # Memory mode + memory_enabled: bool = Field(default=False) + memory_times: str = Field(default="09:00") + memory_collection_mode: str = Field(default="combined") + memory_limit: int = Field(default=10) + memory_favorite_only: bool = Field(default=False) + memory_asset_type: str = Field(default="all") + memory_min_rating: int = Field(default=0) + + created_at: datetime = Field(default_factory=_utcnow) + + +class TemplateConfig(SQLModel, table=True): + """Jinja2 message templates. Tied to a provider type.""" + + __tablename__ = "template_config" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + provider_type: str # Must match provider's type + name: str + description: str = Field(default="") + icon: str = Field(default="") + + # Event-driven notification templates + message_assets_added: str = Field(default="") + message_assets_removed: str = Field(default="") + message_collection_renamed: str = Field(default="") + message_collection_deleted: str = Field(default="") + message_sharing_changed: str = Field(default="") + + # Scheduled notification templates + periodic_summary_message: str = Field(default="") + scheduled_assets_message: str = Field(default="") + memory_mode_message: str = Field(default="") + + date_format: str = Field(default="%d.%m.%Y, %H:%M UTC") + + created_at: datetime = Field(default_factory=_utcnow) + + +class NotificationTarget(SQLModel, table=True): + """Where to send notifications. Owns the template config.""" + + __tablename__ = "notification_target" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + type: str # "telegram" or "webhook" + name: str + icon: str = Field(default="") + config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + template_config_id: int | None = Field(default=None, foreign_key="template_config.id") + created_at: datetime = Field(default_factory=_utcnow) + + +class Tracker(SQLModel, table=True): + """Watches a provider's collections for changes. Owns the tracking config.""" + + __tablename__ = "tracker" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + provider_id: int = Field(foreign_key="service_provider.id") + name: str + icon: str = Field(default="") + collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + target_ids: list[int] = Field(default_factory=list, sa_column=Column(JSON)) + tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id") + scan_interval: int = Field(default=60) + enabled: bool = Field(default=True) + quiet_hours_start: str | None = None + quiet_hours_end: str | None = None + created_at: datetime = Field(default_factory=_utcnow) + + +class TrackerState(SQLModel, table=True): + """Persisted state for change detection.""" + + __tablename__ = "tracker_state" + + id: int | None = Field(default=None, primary_key=True) + tracker_id: int = Field(foreign_key="tracker.id") + collection_id: str + asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + last_updated: datetime = Field(default_factory=_utcnow) + + +class EventLog(SQLModel, table=True): + """Log of detected events.""" + + __tablename__ = "event_log" + + id: int | None = Field(default=None, primary_key=True) + tracker_id: int | None = Field(default=None, foreign_key="tracker.id") + event_type: str + collection_id: str + collection_name: str + details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + created_at: datetime = Field(default_factory=_utcnow) diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py index 6823a3d..c650dbb 100644 --- a/packages/server/src/notify_bridge_server/main.py +++ b/packages/server/src/notify_bridge_server/main.py @@ -1,8 +1,38 @@ """Notify Bridge Server — FastAPI application entry point.""" +from contextlib import asynccontextmanager + from fastapi import FastAPI -app = FastAPI(title="Notify Bridge", version="0.1.0") +from .database.engine import init_db +from .database.models import * # noqa: F401,F403 — ensure all models registered + +from .auth.routes import router as auth_router +from .api.providers import router as providers_router +from .api.trackers import router as trackers_router +from .api.tracking_configs import router as tracking_configs_router +from .api.template_configs import router as template_configs_router +from .api.targets import router as targets_router +from .api.template_vars import router as template_vars_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + await _seed_default_templates() + yield + + +app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan) + +# Register routes — static paths before parameterized +app.include_router(auth_router) +app.include_router(template_vars_router) +app.include_router(providers_router) +app.include_router(trackers_router) +app.include_router(tracking_configs_router) +app.include_router(template_configs_router) +app.include_router(targets_router) @app.get("/api/health") @@ -10,6 +40,38 @@ async def health(): return {"status": "ok"} +async def _seed_default_templates(): + """Seed default templates on first startup if no templates exist.""" + from sqlmodel import func, select + from sqlmodel.ext.asyncio.session import AsyncSession + from .database.engine import get_engine + from .database.models import TemplateConfig + from notify_bridge_core.templates.defaults import load_default_templates + + engine = get_engine() + async with AsyncSession(engine) as session: + result = await session.exec(select(func.count()).select_from(TemplateConfig)) + count = result.one() + if count > 0: + return + + for locale in ("en", "ru"): + slots = load_default_templates(locale) + if not slots: + continue + name = f"Default ({locale.upper()})" + config = TemplateConfig( + user_id=0, + provider_type="immich", + name=name, + description=f"Default Immich templates ({locale.upper()})", + **slots, + ) + session.add(config) + + await session.commit() + + def run(): import uvicorn uvicorn.run(app, host="0.0.0.0", port=8420)