UMBRA/backend/app/routers/settings.py
Kyle Pope 3d22568b9c Add user connections, notification centre, and people integration
Implements the full User Connections & Notification Centre feature:

Phase 1 - Database: migrations 039-043 adding umbral_name to users,
profile/social fields to settings, notifications table, connection
request/user_connection tables, and linked_user_id to people.

Phase 2 - Notifications: backend CRUD router + service + 90-day purge,
frontend NotificationsPage with All/Unread filter, bell icon in sidebar
with unread badge polling every 60s.

Phase 3 - Settings: profile fields (phone, mobile, address, company,
job_title), social card with accept_connections toggle and per-field
sharing defaults, umbral name display with CopyableField.

Phase 4 - Connections: timing-safe user search, send/accept/reject flow
with atomic status updates, bidirectional UserConnection + Person records,
in-app + ntfy notifications, per-receiver pending cap, nginx rate limiting.

Phase 5 - People integration: batch-loaded shared profiles (N+1 prevention),
Ghost icon for umbral contacts, Umbral filter pill, split Add Person button,
shared field indicators (synced labels + Lock icons), disabled form inputs
for synced fields on umbral contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:10:16 +08:00

149 lines
5.4 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings as app_settings
from app.models.settings import Settings
from app.models.user import User
from app.schemas.settings import SettingsUpdate, SettingsResponse
from app.routers.auth import get_current_user, get_current_settings
router = APIRouter()
def _to_settings_response(s: Settings) -> SettingsResponse:
"""
Explicitly construct SettingsResponse, computing ntfy_has_token
from the stored token without ever exposing the token value.
"""
return SettingsResponse(
id=s.id,
user_id=s.user_id,
accent_color=s.accent_color,
upcoming_days=s.upcoming_days,
preferred_name=s.preferred_name,
weather_city=s.weather_city,
weather_lat=s.weather_lat,
weather_lon=s.weather_lon,
first_day_of_week=s.first_day_of_week,
ntfy_server_url=s.ntfy_server_url,
ntfy_topic=s.ntfy_topic,
ntfy_enabled=s.ntfy_enabled,
ntfy_events_enabled=s.ntfy_events_enabled,
ntfy_reminders_enabled=s.ntfy_reminders_enabled,
ntfy_todos_enabled=s.ntfy_todos_enabled,
ntfy_projects_enabled=s.ntfy_projects_enabled,
ntfy_event_lead_minutes=s.ntfy_event_lead_minutes,
ntfy_todo_lead_days=s.ntfy_todo_lead_days,
ntfy_project_lead_days=s.ntfy_project_lead_days,
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
auto_lock_enabled=s.auto_lock_enabled,
auto_lock_minutes=s.auto_lock_minutes,
# Profile fields
phone=s.phone,
mobile=s.mobile,
address=s.address,
company=s.company,
job_title=s.job_title,
# Social settings
accept_connections=s.accept_connections,
# Sharing defaults
share_preferred_name=s.share_preferred_name,
share_email=s.share_email,
share_phone=s.share_phone,
share_mobile=s.share_mobile,
share_birthday=s.share_birthday,
share_address=s.share_address,
share_company=s.share_company,
share_job_title=s.share_job_title,
# ntfy connections toggle
ntfy_connections_enabled=s.ntfy_connections_enabled,
created_at=s.created_at,
updated_at=s.updated_at,
)
@router.get("/", response_model=SettingsResponse)
async def get_settings(
db: AsyncSession = Depends(get_db),
current_settings: Settings = Depends(get_current_settings)
):
"""Get current settings (excluding ntfy auth token)."""
return _to_settings_response(current_settings)
@router.put("/", response_model=SettingsResponse)
async def update_settings(
settings_update: SettingsUpdate,
db: AsyncSession = Depends(get_db),
current_settings: Settings = Depends(get_current_settings)
):
"""Update settings."""
update_data = settings_update.model_dump(exclude_unset=True)
# PT-L02: SSRF-validate ntfy_server_url at save time, not just at dispatch
if "ntfy_server_url" in update_data and update_data["ntfy_server_url"]:
from app.services.ntfy import validate_ntfy_host
try:
validate_ntfy_host(update_data["ntfy_server_url"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
for key, value in update_data.items():
setattr(current_settings, key, value)
await db.commit()
await db.refresh(current_settings)
return _to_settings_response(current_settings)
@router.post("/ntfy/test")
async def test_ntfy(
db: AsyncSession = Depends(get_db),
current_settings: Settings = Depends(get_current_settings)
):
"""
Send a test ntfy notification to verify the user's configuration.
Requires ntfy_server_url and ntfy_topic to be set.
Note: ntfy_enabled does not need to be True to run the test — the service
call bypasses that check because we pass settings directly.
"""
if not current_settings.ntfy_server_url or not current_settings.ntfy_topic:
raise HTTPException(
status_code=400,
detail="ntfy server URL and topic must be configured before sending a test"
)
# SSRF-validate the URL before attempting the outbound request
from app.services.ntfy import validate_ntfy_host, send_ntfy_notification
try:
validate_ntfy_host(current_settings.ntfy_server_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Temporarily treat ntfy as enabled for the test send even if master switch is off
class _TestSettings:
"""Thin wrapper that forces ntfy_enabled=True for the test call."""
ntfy_enabled = True
ntfy_server_url = current_settings.ntfy_server_url
ntfy_topic = current_settings.ntfy_topic
ntfy_auth_token = current_settings.ntfy_auth_token
success = await send_ntfy_notification(
settings=_TestSettings(), # type: ignore[arg-type]
title="UMBRA Test Notification",
message="If you see this, your ntfy integration is working correctly.",
tags=["white_check_mark"],
priority=3,
click_url=app_settings.UMBRA_URL,
)
if not success:
raise HTTPException(
status_code=502,
detail="Failed to deliver test notification — check server URL, topic, and auth token"
)
return {"message": "Test notification sent successfully"}