"""Remote API and mobile/administration pages in the existing Hub.""" import re import secrets from pathlib import Path from uuid import UUID, uuid4 from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field, ConfigDict from app.core.auth_dependencies import get_current_user from app.core.auth_service import AuthService from app.core.database import atomic_transaction, execute_query, execute_query_single from . import service from .security import COOKIE, CSRF_COOKIE, guard, today, digest, lookup, new_code, reserve_attempt, remote_device, actor router = APIRouter(prefix='/api/v1/remote', dependencies=[Depends(guard)]) views = APIRouter(dependencies=[Depends(guard)]) ROOT = Path(__file__).parent class Credentials(BaseModel): code: str = Field(default='', max_length=200) pin: str = Field(max_length=200) name: str = Field(default='Mobil / tablet', min_length=1, max_length=100) class Pin(BaseModel): pin: str = Field(pattern=r'^[0-9]{4}$') class Name(BaseModel): name: str = Field(min_length=1, max_length=100) class Command(BaseModel): model_config = ConfigDict(extra='forbid') request_id: UUID version: int = Field(ge=0) timer_version: str = Field(pattern=r'^[0-9a-f]{64}$') action: Literal['select', 'start', 'pause', 'resume', 'stop', 'manual', 'read', 'ack'] case_id: int | None = Field(default=None, gt=0) time_id: int | None = Field(default=None, gt=0) description: str = Field(default='', max_length=2000) minutes: int | None = Field(default=None, ge=1, le=1440) switch_timer: bool | None = None partner_user_id: int | None = Field(default=None, gt=0) message_id: int | None = Field(default=None, gt=0) class Follow(BaseModel): tab_id: UUID enable: bool = False disable: bool = False after: int = Field(default=0, ge=0) class PhoneCall(BaseModel): model_config = ConfigDict(extra='forbid') request_id: UUID contact_id: int = Field(gt=0) field: Literal['phone', 'mobile'] @router.get('/contacts') async def remote_contacts(request: Request, q: str = Query('', max_length=100), offset: int = Query(0, ge=0)): from . import telephony return await telephony.contacts(actor(remote_device(request)), q, offset) @router.get('/day') def remote_day(request: Request): return service.planned_day(actor(remote_device(request))) @router.get('/telephony') def remote_telephony(request: Request): from . import telephony return telephony.status(actor(remote_device(request))) @router.get('/call/{request_id}') def remote_call_result(request_id: UUID, request: Request): from . import telephony user = actor(remote_device(request)); telephony.require_access(user) row = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id))) if not row or row['payload'].get('action') != 'call': return {'status': 'unknown', 'message': 'Hub har ikke registreret opkaldet. Kontrollér telefonen før et nyt opkald.'} return row['result'] @router.post('/call') async def remote_call(payload: PhoneCall, request: Request): from . import telephony return await telephony.call(payload, request) def authenticated(request: Request): return actor(remote_device(request)) def lock_user(uid): execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,)) @router.get('/bootstrap') def bootstrap(request: Request, response: Response): csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(32) response.set_cookie(CSRF_COOKIE, csrf, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/', max_age=30*86400) status = 'connect' try: device = remote_device(request, unlocked=False) status = 'ready' if device['unlocked_date'] == today() and device['pin_generation'] == device['generation'] else 'locked' except HTTPException: pass return {'csrf': csrf, 'status': status} @router.post('/connect') def connect(payload: Credentials, request: Request, response: Response): remote = execute_query_single('SELECT * FROM remote_controls WHERE code_lookup=%s', (lookup(payload.code),)) reserve_attempt(request.client.host if request.client else 'unknown', (remote or {}).get('user_id')) if not re.fullmatch(r'[0-9]{12}', payload.code) or not re.fullmatch(r'[0-9]{4}', payload.pin) or not remote: raise HTTPException(401, 'Forkert kode eller PIN') with atomic_transaction(): lock_user(remote['user_id']) remote = execute_query_single('SELECT r.*,u.is_active FROM remote_controls r JOIN users u ON u.user_id=r.user_id WHERE r.id=%s AND NOT r.revoked', (str(remote['id']),)) pin = execute_query_single('SELECT * FROM remote_pins WHERE user_id=%s', (remote['user_id'],)) if remote else None if not remote or not remote['is_active'] or not pin or not AuthService.verify_password(payload.code, remote['code_hash']) or not AuthService.verify_password(lookup('pin:' + payload.pin), pin['pin_hash']): raise HTTPException(401, 'Forkert kode eller PIN') token = secrets.token_urlsafe(32) execute_query('''INSERT INTO remote_devices(id,remote_id,name,token_hash,expires_at,unlocked_date,pin_generation) VALUES(%s,%s,%s,%s,NOW()+INTERVAL '30 days',%s,%s)''', (str(uuid4()), str(remote['id']), payload.name.strip() or 'Mobil / tablet', digest(token), today(), pin['generation'])) response.set_cookie(COOKIE, token, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/api/v1/remote', max_age=30*86400) return {'ok': True} @router.post('/unlock') def unlock(payload: Credentials, request: Request): device = remote_device(request, unlocked=False) reserve_attempt(request.client.host if request.client else 'unknown', device['user_id'], device['id']) with atomic_transaction(): lock_user(device['user_id']) device = remote_device(request, unlocked=False) if not re.fullmatch(r'[0-9]{4}', payload.pin) or not AuthService.verify_password(lookup('pin:' + payload.pin), device['pin_hash']): raise HTTPException(401, 'Forkert PIN') execute_query('UPDATE remote_devices SET unlocked_date=%s,pin_generation=%s,last_seen_at=NOW() WHERE id=%s', (today(), device['generation'], str(device['id']))) return {'ok': True} @router.post('/logout') def logout(request: Request, response: Response): device = remote_device(request, unlocked=False) with atomic_transaction(): lock_user(device['user_id']) execute_query('UPDATE remote_devices SET revoked=TRUE WHERE id=%s', (str(device['id']),)) response.delete_cookie(COOKIE, path='/api/v1/remote', secure=request.url.scheme == 'https', httponly=True, samesite='strict') return {'ok': True} @router.get('/state') def get_state(request: Request): device = remote_device(request) with atomic_transaction(): lock_user(device['user_id']) device = remote_device(request) execute_query('UPDATE remote_devices SET last_seen_at=NOW() WHERE id=%s', (str(device['id']),)) return service.state(actor(device)) @router.get('/cases') def list_cases(kind: Literal['new', 'own', 'search'] = 'search', q: str = Query('', max_length=100), offset: int = Query(0, ge=0), user=Depends(authenticated)): return service.cases(user, kind, q, offset) @router.post('/command') async def run_command(payload: Command, request: Request, user=Depends(authenticated)): return await service.command(payload, user, request=request) @router.get('/command-result/{request_id}') def command_result(request_id: UUID, user=Depends(authenticated)): result = execute_query_single('SELECT result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id))) return {'completed': bool(result), 'result': result['result'] if result else None} @router.get('/admin') def admin(user=Depends(get_current_user)): uid = user['id'] remotes = execute_query('SELECT id,name,revoked,created_at FROM remote_controls WHERE user_id=%s ORDER BY created_at DESC', (uid,)) devices = execute_query('''SELECT d.id,d.remote_id,d.name,d.expires_at,d.revoked,d.last_seen_at FROM remote_devices d JOIN remote_controls r ON r.id=d.remote_id WHERE r.user_id=%s ORDER BY d.last_seen_at DESC''', (uid,)) return {'remotes': remotes, 'devices': devices, 'pin_set': bool(execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)))} @router.put('/admin/pin') def set_pin(payload: Pin, user=Depends(get_current_user)): hashed = AuthService.hash_password(lookup('pin:' + payload.pin)) with atomic_transaction(): lock_user(user['id']) execute_query('''INSERT INTO remote_pins(user_id,pin_hash) VALUES(%s,%s) ON CONFLICT(user_id) DO UPDATE SET pin_hash=EXCLUDED.pin_hash,generation=remote_pins.generation+1''', (user['id'], hashed)) return {'ok': True} def create_code(uid, name, remote_id=None): with atomic_transaction(): lock_user(uid) if remote_id and not execute_query_single('SELECT id FROM remote_controls WHERE id=%s AND user_id=%s AND NOT revoked', (str(remote_id), uid)): raise HTTPException(404, 'Fjernbetjeningen findes ikke') if not execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)): raise HTTPException(409, 'Opret din PIN først') # A global advisory lock prevents even extremely unlikely code collisions. execute_query('SELECT pg_advisory_xact_lock(1069,1)') code = new_code() while execute_query_single('SELECT id FROM remote_controls WHERE code_lookup=%s', (lookup(code),)): code = new_code() if remote_id: execute_query('UPDATE remote_controls SET code_lookup=%s,code_hash=%s WHERE id=%s', (lookup(code), AuthService.hash_password(code), str(remote_id))) execute_query('UPDATE remote_devices SET revoked=TRUE WHERE remote_id=%s', (str(remote_id),)) else: remote_id = uuid4() execute_query('INSERT INTO remote_controls(id,user_id,name,code_lookup,code_hash) VALUES(%s,%s,%s,%s,%s)', (str(remote_id), uid, name, lookup(code), AuthService.hash_password(code))) return {'id': str(remote_id), 'code': code} @router.post('/admin/remotes') def create_remote(payload: Name, user=Depends(get_current_user)): return create_code(user['id'], payload.name.strip() or 'Fjernbetjening') @router.post('/admin/remotes/{remote_id}/rotate') def rotate_remote(remote_id: UUID, user=Depends(get_current_user)): return create_code(user['id'], '', remote_id) @router.delete('/admin/remotes/{remote_id}') def revoke_remote(remote_id: UUID, user=Depends(get_current_user)): with atomic_transaction(): lock_user(user['id']) rows = execute_query('UPDATE remote_controls SET revoked=TRUE WHERE id=%s AND user_id=%s RETURNING id', (str(remote_id), user['id'])) if not rows: raise HTTPException(404, 'Fjernbetjeningen findes ikke') return {'ok': True} @router.delete('/admin/devices/{device_id}') def revoke_device(device_id: UUID, user=Depends(get_current_user)): with atomic_transaction(): lock_user(user['id']) rows = execute_query('''UPDATE remote_devices d SET revoked=TRUE FROM remote_controls r WHERE d.remote_id=r.id AND d.id=%s AND r.user_id=%s RETURNING d.id''', (str(device_id), user['id'])) if not rows: raise HTTPException(404, 'Enheden findes ikke') return {'ok': True} @router.post('/follow') def follow(payload: Follow, user=Depends(get_current_user)): with atomic_transaction(): lock_user(user['id']) s = service.user_state(user['id']) if payload.enable: execute_query('''UPDATE remote_user_state SET follower_id=%s,follower_seen_at=NOW(), selection_target=NULL,selection_expires_at=NULL WHERE user_id=%s''', (str(payload.tab_id), user['id'])) return {'following': True, 'sequence': s['selection_seq']} if str(s['follower_id']) != str(payload.tab_id): return {'following': False, 'sequence': s['selection_seq']} if payload.disable: execute_query('UPDATE remote_user_state SET follower_id=NULL,follower_seen_at=NULL,selection_target=NULL WHERE user_id=%s', (user['id'],)) return {'following': False, 'sequence': s['selection_seq']} # A missed heartbeat must not deliver a historical selection on reconnect. connected = bool(s['connected']) execute_query('UPDATE remote_user_state SET follower_seen_at=NOW() WHERE user_id=%s', (user['id'],)) event = None if connected and str(s['selection_target']) == str(payload.tab_id) and s['selection_seq'] > payload.after and s['selection_expires_at'] and s['selection_expires_at'] > service.datetime.now(service.timezone.utc): service.case(s['active_case_id'], user) event = {'case_id': s['active_case_id']} return {'following': True, 'sequence': s['selection_seq'], 'selection': event} @views.get('/remote', response_class=HTMLResponse) def remote_page(): return HTMLResponse((ROOT / 'mobile.html').read_text(), headers={'Cache-Control': 'no-store'}) @views.get('/remote/manage', response_class=HTMLResponse) def manage_page(user=Depends(get_current_user)): return HTMLResponse((ROOT / 'manage.html').read_text(), headers={'Cache-Control': 'no-store'}) @views.get('/remote/manifest.webmanifest') def manifest(): from fastapi.responses import JSONResponse return JSONResponse({'name': 'BMC Hub Fjernbetjening', 'short_name': 'Hub Remote', 'start_url': '/remote', 'display': 'standalone', 'theme_color': '#0f4c75', 'background_color': '#f4f6f8'}, media_type='application/manifest+json')