# -*- coding: utf-8 -*- """1B Space Compositor HTTP controllers. All compositor-related endpoints live under ``/web/compositor/*`` so they're easy to route through Nginx (single-origin HTTPS via ``location /compositor/ { proxy_pass http://127.0.0.1:8081; }``). """ import datetime import logging import secrets from odoo import http, fields from odoo.http import request from odoo.exceptions import UserError, AccessError _logger = logging.getLogger(__name__) try: import jwt except ImportError: # pragma: no cover jwt = None _logger.warning("PyJWT not installed — compositor auth will fail. " "Install with: pip install pyjwt") class CompositorController(http.Controller): """All compositor HTTP endpoints. The OWL shell calls these endpoints (via ``rpc`` service) to: 1. Get a JWT (``/web/compositor/token``) 2. List available apps (``/web/compositor/apps``) 3. Launch an app (``/web/compositor/launch``) 4. Terminate an app (``/web/compositor/terminate``) 5. Ingest audit events from the proxy (``/web/compositor/audit``) The actual compositor proxy (Python aiohttp server, running pywayland + GStreamer + bubblewrap) lives in the sister addon ``one_os_compositor_proxy``. """ # ────────────────────────────────────────────────────────────────────── # JWT issuance # ────────────────────────────────────────────────────────────────────── @http.route("/web/compositor/token", type="json", auth="user", methods=["POST"]) def get_token(self): """Issue a 15-min JWT (HS256) for the compositor proxy. :return: ``{jwt, proxy_url, expires_at, compositor_session_id}`` :raises UserError: if JWT secret is not configured """ if jwt is None: raise UserError("PyJWT is not installed in Odoo's environment.") secret = request.env["ir.config_parameter"].sudo().get_str("compositor.jwt_secret") if not secret: raise UserError( "Compositor JWT secret is not configured. " "Set ir.config_parameter 'compositor.jwt_secret'." ) user = request.env.user # Reuse existing session for this user if they have one active, else create. Session = request.env["compositor.session"] session = Session.search([ ("user_id", "=", user.id), ("state", "in", ["pending", "active", "disconnected"]), ], order="started_at desc", limit=1) if not session: session = Session.create({ "user_id": user.id, "state": "pending", }) session.touch() # Compose JWT payload now = datetime.datetime.utcnow() expires_at = now + datetime.timedelta(minutes=15) payload = { "iss": request.httprequest.host, "aud": "compositor-proxy", "sub": user.id, "db": request.db, "company_ids": user.company_ids.ids, "scope": "compositor", "compositor_session_id": session.compositor_session_id, "iat": now, "exp": expires_at, } token = jwt.encode(payload, secret, algorithm="HS256") # Log issuance request.env["compositor.audit.log"].sudo().log_event( "jwt_issued", user_id=user.id, session_id=session.id, client_ip=request.httprequest.remote_addr, user_agent=request.httprequest.environ.get("HTTP_USER_AGENT", ""), ) # Update session with new JWT info session.write({ "last_jwt_issued_at": fields.Datetime.to_datetime(now), "last_jwt_expires_at": fields.Datetime.to_datetime(expires_at), "state": "active", }) proxy_url = request.env["ir.config_parameter"].sudo().get_str( "compositor.proxy_url", default="http://localhost:8081", ) return { "jwt": token, "proxy_url": proxy_url, "expires_at": expires_at.isoformat() + "Z", "compositor_session_id": session.compositor_session_id, "session_db_id": session.id, } # ────────────────────────────────────────────────────────────────────── # App catalog # ────────────────────────────────────────────────────────────────────── @http.route("/web/compositor/apps", type="json", auth="user", methods=["POST"]) def list_apps(self): """List apps the current user is allowed to launch. Filters by: - ``active = True`` - ``allowed_group_ids`` empty (visible to all internal users) OR current user is in one of the allowed groups - Apps visible in the current user's company """ user = request.env.user domain = [ ("active", "=", True), "|", ("allowed_group_ids", "=", False), ("allowed_group_ids", "in", user.group_ids.ids), ] apps = request.env["compositor.app"].search_read( domain, ["app_id", "name", "description", "icon", "color", "running_session_count"], ) # icon is base64 — leave as-is, the OWL shell will use it directly return {"apps": apps} # ────────────────────────────────────────────────────────────────────── # App launch / terminate # ────────────────────────────────────────────────────────────────────── @http.route("/web/compositor/launch", type="json", auth="user", methods=["POST"]) def launch_app(self, app_id): """Tell the compositor proxy to spawn a sandboxed app. :param app_id: The ``compositor.app.app_id`` (string identifier) :return: ``{signal_url, pid, compositor_surface_id, app_instance_db_id}`` """ user = request.env.user app = request.env["compositor.app"].search([ ("app_id", "=", app_id), "|", ("allowed_group_ids", "=", False), ("allowed_group_ids", "in", user.group_ids.ids), ], limit=1) if not app: raise AccessError(f"You don't have access to launch {app_id!r}") # Get or create the user's active session session = request.env["compositor.session"].search([ ("user_id", "=", user.id), ("state", "in", ["pending", "active", "disconnected"]), ], order="started_at desc", limit=1) if not session: raise UserError("No active compositor session. Call /web/compositor/token first.") # Create an app-instance record (state=launching) inst = request.env["compositor.session.app"].create({ "session_id": session.id, "app_id": app.id, "state": "launching", "title": app.name, }) # Build the launch config to send to the proxy launch_config = app.get_launch_config() launch_config.update({ "compositor_session_id": session.compositor_session_id, "app_instance_db_id": inst.id, "odoo_user_id": user.id, "odoo_db": request.db, }) # In the full impl: POST this to the Python compositor proxy via # aiohttp. For now (proxy not yet built), return a stub response # that the OWL shell can use to develop against. # TODO: replace with real HTTP call once one_os_compositor_proxy is built try: proxy_response = self._call_proxy_launch(launch_config) except NotImplementedError: # Proxy not yet connected — return a stub so the OWL shell can develop _logger.info("Compositor proxy not yet connected — returning stub response for %s", app_id) proxy_response = { "signal_url": None, "pid": -1, "compositor_surface_id": f"stub-{inst.id}", "stub": True, } # Update the app-instance record inst.write({ "state": "running" if not proxy_response.get("stub") else "launching", "pid": proxy_response.get("pid", -1), "signal_url": proxy_response.get("signal_url"), "compositor_surface_id": proxy_response.get("compositor_surface_id"), }) # Audit log request.env["compositor.audit.log"].sudo().log_event( "app_launch", user_id=user.id, session_id=session.id, app_id=app.id, app_instance_id=inst.id, pid=proxy_response.get("pid"), ) return { "signal_url": proxy_response.get("signal_url"), "pid": proxy_response.get("pid"), "compositor_surface_id": proxy_response.get("compositor_surface_id"), "app_instance_db_id": inst.id, "stub": proxy_response.get("stub", False), } @http.route("/web/compositor/terminate", type="json", auth="user", methods=["POST"]) def terminate_app(self, app_instance_db_id, force=False): """Tell the compositor proxy to terminate an app. :param app_instance_db_id: The ID of the ``compositor.session.app`` record :param force: If True, send SIGKILL instead of SIGTERM """ user = request.env.user inst = request.env["compositor.session.app"].browse(app_instance_db_id) if not inst.exists() or inst.session_id.user_id != user: raise AccessError("You don't have access to terminate this app instance.") # TODO: call the proxy to actually kill the process inst.write({ "state": "terminated", "ended_at": fields.Datetime.now(), "signal_received": "SIGKILL" if force else "SIGTERM", }) request.env["compositor.audit.log"].sudo().log_event( "app_force_kill" if force else "app_terminate", user_id=user.id, session_id=inst.session_id.id, app_id=inst.app_id.id, app_instance_id=inst.id, pid=inst.pid, ) return {"ok": True} # ────────────────────────────────────────────────────────────────────── # Audit ingestion (called by the proxy) # ────────────────────────────────────────────────────────────────────── @http.route("/web/compositor/audit", type="json", auth="user", methods=["POST"]) def audit_callback(self, **kwargs): """Ingest audit events from the Python compositor proxy. The proxy calls this endpoint to report app crashes, exits, etc. :param kwargs: Field values for the new ``compositor.audit.log`` record :return: ``{"ok": True}`` """ request.env["compositor.audit.log"].sudo().log_event( kwargs.get("event_type", "app_crash"), user_id=kwargs.get("user_id"), session_id=kwargs.get("session_id"), app_id=kwargs.get("app_id"), app_instance_id=kwargs.get("app_instance_id"), pid=kwargs.get("pid"), exit_code=kwargs.get("exit_code"), signal_received=kwargs.get("signal"), detail=kwargs.get("detail"), client_ip=kwargs.get("client_ip"), ) return {"ok": True} # ────────────────────────────────────────────────────────────────────── # Internal: proxy call # ────────────────────────────────────────────────────────────────────── def _call_proxy_launch(self, launch_config): """Send the launch request to the Python compositor proxy. Returns the proxy's response dict, or raises if the proxy is unreachable. The actual implementation is in ``one_os_compositor_proxy``. This stub raises ``NotImplementedError`` so the controller returns a stub response that the OWL shell can develop against. """ raise NotImplementedError( "Compositor proxy is not yet connected. " "Install the one_os_compositor_proxy addon to enable app launching." ) # Note: `fields` is imported at the top of this file.