from __future__ import annotations from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Any from urllib.parse import quote import httpx class NocoBaseHttpError(RuntimeError): def __init__( self, status_code: int, message: str, *, authentication_failed: bool | None = None, ) -> None: self.status_code = status_code self.authentication_failed = status_code == 401 if authentication_failed is None else authentication_failed super().__init__(message) class NocoBaseHttpClient: """Small synchronous client for the fixed NocoBase attachment endpoints.""" _PROJECT_RESOURCE = "t_projects" _ATTACHMENT_FIELD = "t_projects.f_projects_attachments" def __init__( self, api_base_url: str | None = None, api_token: str | None = None, timeout: float = 30.0, transport: httpx.BaseTransport | None = None, credential_provider: Callable[[], tuple[str, str]] | None = None, ) -> None: self.api_base_url = api_base_url.strip().rstrip("/") if api_base_url else "" self.api_token = api_token.strip() if api_token else "" self._credential_provider = credential_provider if credential_provider is None and not self.api_base_url: raise ValueError("NocoBase API base URL must not be blank") if credential_provider is None and not self.api_token: raise ValueError("NocoBase API token must not be blank") if credential_provider is not None and (self.api_base_url or self.api_token): raise ValueError("Provide static NocoBase credentials or a credential provider, not both") self._client = httpx.Client( timeout=timeout, transport=transport, ) def upload_attachment(self, path: Path, filename: str, mimetype: str | None) -> dict[str, Any]: api_base_url, api_token = self._credentials() self._ensure_project_write_access(api_base_url, api_token) with Path(path).open("rb") as handle: response = self._client.post( f"{api_base_url}/attachments:create", params={"attachmentField": self._ATTACHMENT_FIELD}, files={"file": (filename, handle, mimetype or "application/octet-stream")}, headers={"Authorization": f"Bearer {api_token}"}, ) payload = self._json_response(response, api_token) data = payload.get("data") if not isinstance(data, dict): raise NocoBaseHttpError(response.status_code, "NocoBase response did not contain an attachment object") return data def _ensure_project_write_access(self, api_base_url: str, api_token: str) -> None: response = self._client.get( f"{api_base_url}/roles:check", headers={"Authorization": f"Bearer {api_token}"}, ) payload = self._json_response(response, api_token) role_data = payload.get("data") if not isinstance(role_data, Mapping): raise NocoBaseHttpError(502, "NocoBase roles:check response did not contain role data") effective_role = dict(role_data) meta = payload.get("meta") data_sources = meta.get("dataSources") if isinstance(meta, Mapping) else None main_role = data_sources.get("main") if isinstance(data_sources, Mapping) else None if isinstance(main_role, Mapping): effective_role.update(main_role) if any(_role_allows_action(effective_role, self._PROJECT_RESOURCE, action) for action in ("create", "update")): return raise NocoBaseHttpError(403, "Current NocoBase role cannot create or update projects") def add_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any]) -> None: self._post_attachment_ids(project_id, attachment_ids, "add") def remove_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any]) -> None: self._post_attachment_ids(project_id, attachment_ids, "remove") def _post_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any], action: str) -> None: project_id_segment = _project_id_segment(project_id) api_base_url, api_token = self._credentials() response = self._client.post( f"{api_base_url}/t_projects/{project_id_segment}/f_projects_attachments:{action}", json={"tk": list(attachment_ids)}, headers={"Authorization": f"Bearer {api_token}"}, ) self._raise_for_error(response, api_token) def _json_response(self, response: httpx.Response, api_token: str) -> dict[str, Any]: self._raise_for_error(response, api_token) try: payload = response.json() except ValueError as error: raise NocoBaseHttpError(response.status_code, "NocoBase response was not valid JSON") from error if not isinstance(payload, dict): raise NocoBaseHttpError(response.status_code, "NocoBase response was not a JSON object") return payload def _raise_for_error(self, response: httpx.Response, api_token: str) -> None: if response.is_success: return message = response.text.strip() or response.reason_phrase or "NocoBase request failed" raise NocoBaseHttpError( response.status_code, self._redact(message, api_token), authentication_failed=_is_authentication_failure(response), ) def _credentials(self) -> tuple[str, str]: try: api_base_url, api_token = ( self._credential_provider() if self._credential_provider is not None else (self.api_base_url, self.api_token) ) except NocoBaseHttpError: raise except Exception as error: raise NocoBaseHttpError(401, str(error), authentication_failed=True) from error normalized_url = str(api_base_url).strip().rstrip("/") normalized_token = str(api_token).strip() if not normalized_url or not normalized_token: raise NocoBaseHttpError(401, "NocoBase CLI environment is not authenticated", authentication_failed=True) return normalized_url, normalized_token @staticmethod def _redact(value: str, api_token: str) -> str: return value.replace(api_token, "") def _project_id_segment(project_id: Any) -> str: return quote(validate_project_id(project_id), safe="") def _role_allows_action(role_data: Mapping[str, Any], resource: str, action: str) -> bool: if role_data.get("allowAll") is True: return True aliases = role_data.get("actionAlias") resolved_action = aliases.get(action, action) if isinstance(aliases, Mapping) else action resources = role_data.get("resources") if isinstance(resources, Sequence) and not isinstance(resources, (str, bytes)) and resource in resources: actions = role_data.get("actions") if not isinstance(actions, Mapping): return False for action_path in (f"{resource}:{resolved_action}", f"{resource}:{action}"): if action_path in actions and actions[action_path] is not None and actions[action_path] is not False: return True return False strategy = role_data.get("strategy") strategy_actions = strategy.get("actions") if isinstance(strategy, Mapping) else None if not isinstance(strategy_actions, Sequence) or isinstance(strategy_actions, (str, bytes)): return False return any( isinstance(strategy_action, str) and strategy_action.split(":", 1)[0] == resolved_action for strategy_action in strategy_actions ) def validate_project_id(project_id: Any) -> str: if isinstance(project_id, bool) or not isinstance(project_id, (str, int)): raise ValueError("project_id must be a scalar identifier") if isinstance(project_id, str): if not project_id.strip(): raise ValueError("project_id must not be blank") if project_id in {".", ".."}: raise ValueError("project_id must not be a dot path segment") return str(project_id) def _is_authentication_failure(response: httpx.Response) -> bool: if response.status_code == 401: return True response_text = response.text.upper() return any( code in response_text for code in ( "EMPTY_TOKEN", "INVALID_TOKEN", "EXPIRED_TOKEN", "BLOCKED_TOKEN", "EXPIRED_SESSION", "NOT_EXIST_USER", ) )