- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads. - Added tests for v5 assignment functionality, validating employee identification and group assignment. - Implemented tests for remote recovery scenarios, including handling network failures and response validation. - Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation. - Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions. - Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
299 lines
14 KiB
Python
299 lines
14 KiB
Python
"""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')
|