2026-05-12 00:47:06 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
2026-05-12 14:51:53 +08:00
|
|
|
import os
|
2026-05-12 00:47:06 +08:00
|
|
|
from typing import Any
|
2026-05-12 14:51:53 +08:00
|
|
|
from urllib.parse import urlparse
|
2026-05-12 00:47:06 +08:00
|
|
|
|
|
|
|
|
import aiohttp
|
|
|
|
|
|
|
|
|
|
from yuxi.channels.exceptions import ChannelNotConnectedError
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 14:51:53 +08:00
|
|
|
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()
|
2026-05-12 00:47:06 +08:00
|
|
|
|
|
|
|
|
|
2026-05-12 14:51:53 +08:00
|
|
|
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:
|
2026-05-12 00:47:06 +08:00
|
|
|
return False
|
2026-05-12 14:51:53 +08:00
|
|
|
expected = _compute_bot_signature(body, random_header, secret)
|
2026-05-12 00:47:06 +08:00
|
|
|
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 {}
|
2026-05-12 14:51:53 +08:00
|
|
|
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}")
|
2026-05-12 00:47:06 +08:00
|
|
|
|
|
|
|
|
async def get(self, path: str, **kwargs) -> dict[str, Any]:
|
|
|
|
|
url = self.api_url(path)
|
2026-05-12 14:51:53 +08:00
|
|
|
self._validate_url(url)
|
2026-05-12 00:47:06 +08:00
|
|
|
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
|
2026-05-12 14:51:53 +08:00
|
|
|
headers.update(self._sign_body(b""))
|
2026-05-12 00:47:06 +08:00
|
|
|
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)
|
2026-05-12 14:51:53 +08:00
|
|
|
self._validate_url(url)
|
2026-05-12 00:47:06 +08:00
|
|
|
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)
|
2026-05-12 14:51:53 +08:00
|
|
|
self._validate_url(url)
|
2026-05-12 00:47:06 +08:00
|
|
|
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
|
2026-05-12 14:51:53 +08:00
|
|
|
headers.update(self._sign_body(b""))
|
2026-05-12 00:47:06 +08:00
|
|
|
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)
|
2026-05-12 14:51:53 +08:00
|
|
|
self._validate_url(url)
|
2026-05-12 00:47:06 +08:00
|
|
|
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)
|
2026-05-12 14:51:53 +08:00
|
|
|
self._validate_url(url)
|
2026-05-12 00:47:06 +08:00
|
|
|
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
|
2026-05-12 14:51:53 +08:00
|
|
|
headers.update(self._sign_body(b""))
|
2026-05-12 00:47:06 +08:00
|
|
|
headers.update(kwargs.pop("headers", {}))
|
|
|
|
|
async with self.session.delete(url, headers=headers, **kwargs) as resp:
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
return await resp.json()
|