203 lines
8.5 KiB
Python
203 lines
8.5 KiB
Python
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, "<redacted>")
|
|
|
|
|
|
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",
|
|
)
|
|
)
|