## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later from datetime import datetime, timedelta, timezone import json from uuid import uuid4 from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from backend.app.core.config import settings from backend.app.database import get_connection, hash_password from backend.app.services.email_service import get_smtp_settings, save_smtp_settings, send_test_email from backend.app.core.errors import public_error, redacted_error, request_id import logging router = APIRouter() logger = logging.getLogger(__name__) class SetupRequest(BaseModel): username: str email: str password: str smtp_host: str smtp_port: int = 587 smtp_username: str = '' smtp_password: str = '' smtp_from: str smtp_use_tls: bool = True class TestMailRequest(BaseModel): email: str | None = None def has_administrator() -> bool: with get_connection() as conn: return conn.execute('SELECT 1 FROM users WHERE is_admin = 1 LIMIT 1').fetchone() is not None def get_pending_setup() -> dict | None: with get_connection() as conn: row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_pending',)).fetchone() return json.loads(row['value']) if row else None def save_pending_setup(values: dict) -> None: with get_connection() as conn: conn.execute( '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', ('setup_pending', json.dumps(values)), ) conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_mail_rate',)) conn.commit() def delete_pending_setup() -> None: with get_connection() as conn: conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_pending',)) conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_mail_rate',)) conn.commit() @router.post('/configuration', status_code=200) def save_configuration(payload: SetupRequest): if has_administrator(): raise HTTPException(status_code=409, detail='LinkLog is already configured') username = payload.username.strip() email = payload.email.strip() smtp_host = payload.smtp_host.strip() smtp_from = payload.smtp_from.strip() if not username or not email or not smtp_host or not smtp_from or len(payload.password) < 8: raise HTTPException(status_code=422, detail='Admin credentials and SMTP settings are required') if not 1 <= payload.smtp_port <= 65535: raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535') smtp_values = { 'smtp_host': smtp_host, 'smtp_port': payload.smtp_port, 'smtp_username': payload.smtp_username.strip(), 'smtp_password': payload.smtp_password, 'smtp_from': smtp_from, 'smtp_use_tls': payload.smtp_use_tls, } pending = { 'username': username, 'email': email, 'password_hash': hash_password(payload.password), } save_pending_setup(pending) save_smtp_settings(smtp_values) return {'status': 'saved', 'message': 'Configuration saved. Send a test mail to verify SMTP delivery.'} @router.post('/test-mail') def test_mail(request: Request, payload: TestMailRequest | None = None): if has_administrator(): raise HTTPException(status_code=409, detail='LinkLog is already configured') pending = get_pending_setup() if pending is None: raise HTTPException(status_code=400, detail='Save the configuration before sending test mail') now = datetime.now(timezone.utc) with get_connection() as conn: row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone() rate = json.loads(row['value']) if row else {} last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None if cooldown_until and now >= cooldown_until: rate = {} last_sent = None cooldown_until = None if cooldown_until and now < cooldown_until: retry_after = int((cooldown_until - now).total_seconds()) + 1 raise HTTPException(status_code=429, detail=f'Test mail limit reached. Try again in {retry_after} seconds.', headers={'Retry-After': str(retry_after)}) if last_sent and now - last_sent < timedelta(seconds=20): retry_after = int((timedelta(seconds=20) - (now - last_sent)).total_seconds()) + 1 raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before sending another test mail.', headers={'Retry-After': str(retry_after)}) try: send_test_email(payload.email if payload and payload.email else pending['email']) except Exception as error: logger.error('SMTP test mail failed request_id=%s error=%s', request_id(request), redacted_error(error)) raise HTTPException(status_code=503, detail=public_error(request, 'SMTP test mail could not be sent.')) from error sends = int(rate.get('sends', 0)) + 1 updated_rate = {'sends': sends, 'last_sent': now.isoformat()} if sends >= 5: updated_rate['cooldown_until'] = (now + timedelta(minutes=2)).isoformat() with get_connection() as conn: conn.execute( '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', ('setup_mail_rate', json.dumps(updated_rate)), ) conn.commit() next_allowed = datetime.fromisoformat(updated_rate.get('cooldown_until')) if sends >= 5 else now + timedelta(seconds=20) return { 'status': 'sent', 'message': 'SMTP test mail sent.', 'sends_remaining': max(0, 5 - sends), 'cooldown_seconds': 120 if sends >= 5 else 0, 'next_allowed_at': next_allowed.isoformat(), } @router.post('/complete', status_code=201) def complete_setup(): if has_administrator(): raise HTTPException(status_code=409, detail='LinkLog is already configured') pending = get_pending_setup() if pending is None: raise HTTPException(status_code=400, detail='Save the configuration before completing setup') with get_connection() as conn: row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone() if row is None or int(json.loads(row['value']).get('sends', 0)) < 1: raise HTTPException(status_code=400, detail='Send a successful test mail before completing setup') user_id = str(uuid4()) with get_connection() as conn: try: conn.execute( '''INSERT INTO users (id, username, email, password_hash, is_admin, email_verified) VALUES (?, ?, ?, ?, 1, 1)''', (user_id, pending['username'], pending['email'], pending['password_hash']), ) conn.commit() except Exception as error: raise HTTPException(status_code=409, detail='Username or email already exists') from error delete_pending_setup() return {'status': 'configured', 'message': 'LinkLog is configured.'} @router.get('/status') def setup_status(): rate = {} with get_connection() as conn: row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone() if row: rate = json.loads(row['value']) now = datetime.now(timezone.utc) cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None next_allowed = cooldown_until if cooldown_until and cooldown_until > now else ( last_sent + timedelta(seconds=20) if last_sent else None ) return { 'configured': has_administrator(), 'pending': get_pending_setup() is not None, 'smtp_defaults': get_smtp_settings(), 'sends_remaining': max(0, 5 - int(rate.get('sends', 0))), 'next_allowed_at': next_allowed.isoformat() if next_allowed and next_allowed > now else None, }