""" Nextcloud Module - API Router """ import json import logging import secrets from typing import List, Optional from fastapi import APIRouter, HTTPException, Query from app.core.crypto import encrypt_secret from app.core.database import execute_query from app.core.contact_utils import get_primary_customer_id from app.modules.nextcloud.backend.service import NextcloudService from app.modules.nextcloud.models.schemas import ( NextcloudInstanceCreate, NextcloudInstanceUpdate, NextcloudUserCreate, NextcloudPasswordReset, ) logger = logging.getLogger(__name__) router = APIRouter() service = NextcloudService() def _resolve_customer_id(customer_id: Optional[int], contact_id: Optional[int]) -> Optional[int]: if customer_id is not None: return customer_id if contact_id is not None: return get_primary_customer_id(contact_id) return None def _require_customer_id(customer_id: Optional[int], contact_id: Optional[int]) -> Optional[int]: resolved = _resolve_customer_id(customer_id, contact_id) if contact_id is not None and resolved is None: raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde") return resolved def _audit(customer_id: int, instance_id: int, event_type: str, request_meta: dict, response_meta: dict): query = """ INSERT INTO nextcloud_audit_log (customer_id, instance_id, event_type, request_meta, response_meta) VALUES (%s, %s, %s, %s, %s) """ execute_query( query, ( customer_id, instance_id, event_type, json.dumps(request_meta), json.dumps(response_meta), ), ) @router.get("/instances") async def list_instances( customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) query = "SELECT * FROM nextcloud_instances WHERE deleted_at IS NULL" params: List[int] = [] if resolved_customer_id is not None: query += " AND customer_id = %s" params.append(resolved_customer_id) return execute_query(query, tuple(params)) or [] @router.get("/customers/{customer_id}/instance") async def get_instance_for_customer(customer_id: int): query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL" result = execute_query(query, (customer_id,)) if not result: return None return result[0] @router.get("/contacts/{contact_id}/instance") async def get_instance_for_contact(contact_id: int): customer_id = _resolve_customer_id(None, contact_id) if not customer_id: return None query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL" result = execute_query(query, (customer_id,)) if not result: return None return result[0] @router.post("/instances") async def create_instance(payload: NextcloudInstanceCreate): try: password_encrypted = encrypt_secret(payload.password) query = """ INSERT INTO nextcloud_instances (customer_id, base_url, auth_type, username, password_encrypted) VALUES (%s, %s, %s, %s, %s) RETURNING * """ result = execute_query( query, ( payload.customer_id, payload.base_url, payload.auth_type, payload.username, password_encrypted, ), ) return result[0] if result else None except Exception as exc: logger.error("❌ Failed to create Nextcloud instance: %s", exc) raise HTTPException(status_code=500, detail="Failed to create instance") @router.patch("/instances/{instance_id}") async def update_instance(instance_id: int, payload: NextcloudInstanceUpdate): updates = [] params = [] if payload.base_url is not None: updates.append("base_url = %s") params.append(payload.base_url) if payload.auth_type is not None: updates.append("auth_type = %s") params.append(payload.auth_type) if payload.username is not None: updates.append("username = %s") params.append(payload.username) if payload.password is not None: updates.append("password_encrypted = %s") params.append(encrypt_secret(payload.password)) if payload.is_enabled is not None: updates.append("is_enabled = %s") params.append(payload.is_enabled) if not updates: raise HTTPException(status_code=400, detail="No fields to update") updates.append("updated_at = NOW()") params.append(instance_id) query = f"UPDATE nextcloud_instances SET {', '.join(updates)} WHERE id = %s RETURNING *" result = execute_query(query, tuple(params)) if not result: raise HTTPException(status_code=404, detail="Instance not found") return result[0] @router.post("/instances/{instance_id}/disable") async def disable_instance(instance_id: int): query = """ UPDATE nextcloud_instances SET is_enabled = false, disabled_at = NOW(), updated_at = NOW() WHERE id = %s RETURNING * """ result = execute_query(query, (instance_id,)) if not result: raise HTTPException(status_code=404, detail="Instance not found") return result[0] @router.post("/instances/{instance_id}/enable") async def enable_instance(instance_id: int): query = """ UPDATE nextcloud_instances SET is_enabled = true, disabled_at = NULL, updated_at = NOW() WHERE id = %s RETURNING * """ result = execute_query(query, (instance_id,)) if not result: raise HTTPException(status_code=404, detail="Instance not found") return result[0] @router.post("/instances/{instance_id}/rotate-credentials") async def rotate_credentials(instance_id: int, payload: NextcloudInstanceUpdate): if not payload.password: raise HTTPException(status_code=400, detail="Password is required") query = """ UPDATE nextcloud_instances SET password_encrypted = %s, updated_at = NOW() WHERE id = %s RETURNING * """ result = execute_query(query, (encrypt_secret(payload.password), instance_id)) if not result: raise HTTPException(status_code=404, detail="Instance not found") return result[0] @router.get("/instances/{instance_id}/status") async def get_status( instance_id: int, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) response = await service.get_status(instance_id, resolved_customer_id) if resolved_customer_id is not None: _audit(resolved_customer_id, instance_id, "status", {"instance_id": instance_id}, response) return response @router.get("/instances/{instance_id}/groups") async def list_groups( instance_id: int, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) response = await service.list_groups(instance_id, resolved_customer_id) if resolved_customer_id is not None: _audit(resolved_customer_id, instance_id, "groups", {"instance_id": instance_id}, response) return response @router.get("/instances/{instance_id}/users") async def list_users( instance_id: int, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), search: Optional[str] = Query(None), include_details: bool = Query(False), limit: int = Query(200, ge=1, le=500), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) if include_details: response = await service.list_users_details(instance_id, resolved_customer_id, search, limit) else: response = await service.list_users(instance_id, resolved_customer_id, search) if resolved_customer_id is not None: _audit( resolved_customer_id, instance_id, "users", { "instance_id": instance_id, "search": search, "include_details": include_details, "limit": limit, }, response, ) return response @router.get("/instances/{instance_id}/users/{uid}") async def get_user_details( instance_id: int, uid: str, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) response = await service.get_user_details(instance_id, uid, resolved_customer_id) if resolved_customer_id is not None: _audit( resolved_customer_id, instance_id, "user_details", {"instance_id": instance_id, "uid": uid}, response, ) return response @router.get("/instances/{instance_id}/shares") async def list_shares( instance_id: int, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) response = await service.list_public_shares(instance_id, resolved_customer_id) if resolved_customer_id is not None: _audit(resolved_customer_id, instance_id, "shares", {"instance_id": instance_id}, response) return response @router.post("/instances/{instance_id}/users") async def create_user( instance_id: int, payload: NextcloudUserCreate, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) password = secrets.token_urlsafe(12) request_payload = { "userid": payload.uid, "password": password, "email": payload.email, "displayName": payload.display_name, "groups[]": payload.groups, } response = await service.create_user(instance_id, resolved_customer_id, request_payload) if resolved_customer_id is not None: _audit(resolved_customer_id, instance_id, "create_user", {"uid": payload.uid}, response) return {"result": response, "generated_password": password if payload.send_welcome else None} @router.post("/instances/{instance_id}/users/{uid}/reset-password") async def reset_password( instance_id: int, uid: str, payload: NextcloudPasswordReset, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): password = secrets.token_urlsafe(12) resolved_customer_id = _require_customer_id(customer_id, contact_id) response = await service.reset_password(instance_id, resolved_customer_id, uid, password) if resolved_customer_id is not None: _audit(resolved_customer_id, instance_id, "reset_password", {"uid": uid}, response) return {"result": response, "generated_password": password if payload.send_email else None} @router.post("/instances/{instance_id}/users/{uid}/disable") async def disable_user( instance_id: int, uid: str, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) response = await service.disable_user(instance_id, resolved_customer_id, uid) if resolved_customer_id is not None: _audit(resolved_customer_id, instance_id, "disable_user", {"uid": uid}, response) return response @router.post("/instances/{instance_id}/users/{uid}/resend-guide") async def resend_guide( instance_id: int, uid: str, customer_id: Optional[int] = Query(None), contact_id: Optional[int] = Query(None), ): resolved_customer_id = _require_customer_id(customer_id, contact_id) response = {"status": "queued", "uid": uid} if resolved_customer_id is not None: _audit(resolved_customer_id, instance_id, "resend_guide", {"uid": uid}, response) return response @router.get("/audit") async def list_audit( customer_id: int = Query(...), instance_id: Optional[int] = Query(None), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), ): query = """ SELECT * FROM nextcloud_audit_log WHERE customer_id = %s """ params: List[object] = [customer_id] if instance_id is not None: query += " AND instance_id = %s" params.append(instance_id) query += " ORDER BY created_at DESC LIMIT %s OFFSET %s" params.extend([limit, offset]) return execute_query(query, tuple(params)) or [] @router.post("/audit/purge") async def purge_audit(data: dict): customer_id = data.get("customer_id") before_date = data.get("before_date") if not customer_id or not before_date: raise HTTPException(status_code=400, detail="customer_id and before_date are required") query = """ DELETE FROM nextcloud_audit_log WHERE customer_id = %s AND created_at < %s """ deleted = execute_query(query, (customer_id, before_date)) return {"deleted": deleted}