Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_apps.py
T

208 lines
7.8 KiB
Python

from __future__ import annotations
import re
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..db.models import App, AppContainerMapping, DiscoveredContainer
from ..db.session import get_session
from ..core.config import settings
from ..services import safety_center_service
from ..services.app_registry import category_label, list_active_apps
from ..services.audit_logger import log_event
from ..services.inventory_discovery import serialize_inventory_app, sync_app_connector_statuses
router = APIRouter(prefix='/api/apps')
def _published_port(values: list | None) -> int | None:
for value in values or []:
match = re.match(r'^(\d+):\d+', str(value))
if match:
return int(match.group(1))
return None
def _credential_free_url(raw: str) -> str | None:
raw = str(raw or '').strip()
if not raw:
return None
parsed = urlsplit(raw if '://' in raw else f'http://{raw}')
try:
hostname = parsed.hostname
parsed_port = parsed.port
except ValueError:
return None
if parsed.scheme not in {'http', 'https'} or not hostname:
return None
host = f'[{hostname}]' if ':' in hostname else hostname
port = f':{parsed_port}' if parsed_port else ''
path = parsed.path.rstrip('/')
return f'{parsed.scheme}://{host}{port}{path}'
def _base_runtime_url() -> str | None:
safe = _credential_free_url(str(settings.apis.get('UNRAID_URL') or settings.apis.get('PORTAINER_URL') or ''))
if not safe:
return None
parsed = urlsplit(safe)
return f'{parsed.scheme}://{parsed.netloc}'
def _runtime_hints_by_app(session: Session) -> dict[str, dict]:
from ..services.inventory_discovery import _latest_by_container
configured_urls = {
'sonarr': 'SONARR_URL',
'radarr': 'RADARR_URL',
'prowlarr': 'PROWLARR_URL',
'deluge': 'DELUGE_URL',
'plex': 'PLEX_URL',
'tautulli': 'TAUTULLI_URL',
'nginx_proxy_manager': 'NPM_URL',
'authentik': 'AUTHENTIK_URL',
'home_assistant': 'HOME_ASSISTANT_URL',
'widefrog': 'WIDEFROG_URL',
'portainer': 'PORTAINER_URL',
'unraid': 'UNRAID_URL',
}
hints: dict[str, dict] = {}
for app_id, env_name in configured_urls.items():
value = _credential_free_url(str(settings.apis.get(env_name) or ''))
if value:
hints[app_id] = {'url': value}
base_url = _base_runtime_url()
latest = _latest_by_container(session)
mappings = session.exec(select(AppContainerMapping).order_by(AppContainerMapping.is_primary.desc())).all()
for mapping in mappings:
if mapping.app_id in hints or not base_url:
continue
item = latest.get(mapping.container_id)
port = _published_port(item.ports_json if item else None)
if port:
hints[mapping.app_id] = {'url': base_url, 'port': port}
return hints
def _serialize_app(app: App, hints: dict | None = None) -> dict:
return {
'id': app.id,
'name': app.name,
'category': app.category,
'status': app.status,
'criticality': app.criticality,
'connectorStatus': app.connector_status,
'containerStatus': app.container_status,
'voiceEnabled': app.voice_enabled,
'lastAction': app.last_action,
'protected': app.protected,
'discoveryStatus': app.discovery_status,
'discoverySource': app.discovery_source,
'needsReview': app.needs_review,
**(hints or {}),
}
def _serialize_discovered_app(item: DiscoveredContainer) -> dict:
return serialize_inventory_app(item)
@router.get('')
def list_apps(include_discovered: bool = False, session: Session = Depends(get_session)):
sync_app_connector_statuses(session)
runtime_hints = _runtime_hints_by_app(session)
apps = [_serialize_app(app, runtime_hints.get(app.id)) for app in list_active_apps(session)]
if include_discovered:
managed_container_ids = {
mapping.container_id for mapping in session.exec(select(AppContainerMapping)).all()
}
latest_by_container: dict[str, DiscoveredContainer] = {}
for item in session.exec(select(DiscoveredContainer)).all():
latest_by_container[item.container_id] = item
for item in latest_by_container.values():
if item.container_id in managed_container_ids or item.import_status == 'managed':
continue
if item.import_status in {'discovered', 'unmapped', 'changed', 'ignored'}:
apps.append(_serialize_discovered_app(item))
return apps
@router.get('/{app_id}')
def get_app(app_id: str, session: Session = Depends(get_session)):
sync_app_connector_statuses(session)
app = session.get(App, app_id)
if app:
return _serialize_app(app, _runtime_hints_by_app(session).get(app.id))
if app_id.startswith('discovered-'):
discovered_id = app_id.replace('discovered-', '', 1)
item = session.get(DiscoveredContainer, discovered_id)
if item:
return _serialize_discovered_app(item)
return None
@router.patch('/{app_id}/metadata')
def update_app_metadata(app_id: str, payload: dict, session: Session = Depends(get_session)):
app = session.get(App, app_id)
if not app:
return {'success': False, 'status': 'not_found', 'message': 'app_not_found'}
name = str(payload.get('name') or '').strip()[:80]
category = re.sub(r'[^a-zA-Z0-9_\- ]', '', str(payload.get('category') or '')).strip()[:40]
criticality = str(payload.get('criticality') or '').strip().lower()
if not name or not category or criticality not in {'low', 'medium', 'high', 'critical'}:
return {'success': False, 'status': 'validation_error', 'message': 'invalid_app_metadata'}
before = {'name': app.name, 'category': app.category, 'criticality': app.criticality}
app.name = name
app.category = category_label(category)
app.criticality = criticality
session.add(app)
session.commit()
session.refresh(app)
safety_center_service.invalidate_cache()
log_event(
session,
source='manual',
identity='operator',
action_id='app.metadata.update',
target=app.id,
target_type='app',
risk='low',
policy_decision='allowed',
reason='Operator updated AppOps registry metadata only; no container or external service was mutated.',
result='success',
before_state=before,
after_state={'name': app.name, 'category': app.category, 'criticality': app.criticality},
redacted_metadata={'fields': ['name', 'category', 'criticality']},
)
return {'success': True, 'status': 'success', 'message': 'app_metadata_updated', 'app': _serialize_app(app, _runtime_hints_by_app(session).get(app.id))}
@router.patch('/{app_id}/container-mapping')
def update_container_mapping(app_id: str, payload: dict, session: Session = Depends(get_session)):
container_id = str(payload.get('containerId') or '')
container_name = str(payload.get('containerName') or container_id)
if not session.get(App, app_id):
return {'success': False, 'message': 'app_not_found'}
if not container_id:
return {'success': False, 'message': 'container_id_required'}
mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == container_id)).first()
if not mapping:
mapping = AppContainerMapping(id=f'map-{container_id}', app_id=app_id, container_id=container_id, container_name=container_name)
else:
mapping.app_id = app_id
mapping.container_name = container_name
session.add(mapping)
session.commit()
safety_center_service.invalidate_cache()
return {'success': True, 'message': 'mapping_updated', 'mappingId': mapping.id}