from __future__ import annotations import base64 import hashlib import hmac import json import logging import os from typing import Any from urllib.parse import urlparse import aiohttp from yuxi.channels.exceptions import ChannelNotConnectedError logger = logging.getLogger(__name__) def _compute_bot_signature(body: bytes, random_hex: str, secret: str) -> str: payload = random_hex.encode() + body return base64.b64encode(hmac.digest(secret.encode(), payload, hashlib.sha256)).decode() def verify_hmac_signature(body: bytes, random_header: str, signature_header: str, secret: str) -> bool: if not random_header or not signature_header or not secret: return False expected = _compute_bot_signature(body, random_header, secret) return hmac.compare_digest(expected, signature_header) class NextcloudTalkClient: def __init__( self, server_url: str, bot_user: str, app_password: str, proxy: str | None = None, bot_secret: str | None = None, ): self.server_url = server_url.rstrip("/") self.bot_user = bot_user self.basic_auth = base64.b64encode(f"{bot_user}:{app_password}".encode()).decode() self.proxy = proxy self.bot_secret = bot_secret self._session: aiohttp.ClientSession | None = None async def start(self) -> None: if self._session is None: connector_kwargs: dict[str, Any] = {"limit": 10, "ttl_dns_cache": 300} timeout = aiohttp.ClientTimeout(total=60) session_kwargs: dict[str, Any] = { "connector": aiohttp.TCPConnector(**connector_kwargs), "timeout": timeout, "headers": {"User-Agent": "ForcePilot-NextcloudTalk-Adapter/1.0"}, } if self.proxy: session_kwargs["proxy"] = self.proxy self._session = aiohttp.ClientSession(**session_kwargs) async def stop(self) -> None: if self._session: await self._session.close() self._session = None @property def session(self) -> aiohttp.ClientSession: if self._session is None: raise ChannelNotConnectedError() return self._session def auth_headers(self) -> dict[str, str]: return {"Authorization": f"Basic {self.basic_auth}"} def api_url(self, path: str) -> str: return f"{self.server_url}{path}" def _sign_body(self, body_bytes: bytes) -> dict[str, str]: if not self.bot_secret: return {} random_hex = os.urandom(32).hex() signature = _compute_bot_signature(body_bytes, random_hex, self.bot_secret) return { "X-Nextcloud-Talk-Bot-Random": random_hex, "X-Nextcloud-Talk-Bot-Signature": signature, } def _validate_url(self, url: str) -> None: parsed = urlparse(url) server_parsed = urlparse(self.server_url) if parsed.scheme not in ("https", "http"): raise ValueError(f"Invalid URL scheme: {parsed.scheme}") if parsed.hostname != server_parsed.hostname: raise ValueError(f"SSRF blocked: external host {parsed.hostname} != {server_parsed.hostname}") async def get(self, path: str, **kwargs) -> dict[str, Any]: url = self.api_url(path) self._validate_url(url) headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"} headers.update(self._sign_body(b"")) headers.update(kwargs.pop("headers", {})) async with self.session.get(url, headers=headers, **kwargs) as resp: resp.raise_for_status() return await resp.json() async def post(self, path: str, json_data: dict[str, Any] | None = None, **kwargs) -> dict[str, Any]: url = self.api_url(path) self._validate_url(url) headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"} body_bytes = json.dumps(json_data).encode() if json_data else b"" headers.update(self._sign_body(body_bytes)) headers.update(kwargs.pop("headers", {})) async with self.session.post(url, headers=headers, json=json_data, **kwargs) as resp: resp.raise_for_status() return await resp.json() async def post_form(self, path: str, form: aiohttp.FormData, **kwargs) -> dict[str, Any]: url = self.api_url(path) self._validate_url(url) headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"} headers.update(self._sign_body(b"")) headers.update(kwargs.pop("headers", {})) async with self.session.post(url, headers=headers, data=form, **kwargs) as resp: resp.raise_for_status() return await resp.json() async def put(self, path: str, json_data: dict[str, Any] | None = None, **kwargs) -> dict[str, Any]: url = self.api_url(path) self._validate_url(url) headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"} body_bytes = json.dumps(json_data).encode() if json_data else b"" headers.update(self._sign_body(body_bytes)) headers.update(kwargs.pop("headers", {})) async with self.session.put(url, headers=headers, json=json_data, **kwargs) as resp: resp.raise_for_status() return await resp.json() async def delete(self, path: str, **kwargs) -> dict[str, Any]: url = self.api_url(path) self._validate_url(url) headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"} headers.update(self._sign_body(b"")) headers.update(kwargs.pop("headers", {})) async with self.session.delete(url, headers=headers, **kwargs) as resp: resp.raise_for_status() return await resp.json()