deeptutor-server 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Request-scoped delivery of generated output artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
8
|
+
from fastapi.responses import FileResponse
|
|
9
|
+
|
|
10
|
+
from deeptutor.api.routers.auth import require_auth
|
|
11
|
+
from deeptutor.multi_user.context import get_current_user_or_none
|
|
12
|
+
from deeptutor.multi_user.partner_access import visible_partners
|
|
13
|
+
from deeptutor.multi_user.paths import get_current_path_service, get_path_service_for_scope
|
|
14
|
+
from deeptutor.services.auth import TokenPayload
|
|
15
|
+
from deeptutor.services.path_service import PathService
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
# Video lessons are pages of model-written script. Served under this CSP they
|
|
20
|
+
# run in an opaque origin even when opened directly, never with the API's
|
|
21
|
+
# cookies or same-origin access.
|
|
22
|
+
_SANDBOXED_PAGE_CSP = "sandbox allow-scripts allow-popups"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _request_path_service() -> PathService:
|
|
26
|
+
"""Resolve the workspace installed by ``require_auth`` without fallback.
|
|
27
|
+
|
|
28
|
+
The general-purpose ``get_path_service()`` retains a compatibility fallback
|
|
29
|
+
to the local admin workspace for non-request callers. A download endpoint
|
|
30
|
+
must fail closed instead: otherwise an authentication/context regression
|
|
31
|
+
could expose an administrator artifact to an ordinary request.
|
|
32
|
+
"""
|
|
33
|
+
user = get_current_user_or_none()
|
|
34
|
+
if user is None:
|
|
35
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Output not found")
|
|
36
|
+
return get_current_path_service()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_output(path_service: PathService, relative_path: str) -> Path:
|
|
40
|
+
output_path = path_service.resolve_public_output_path(relative_path)
|
|
41
|
+
if output_path is None:
|
|
42
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Output not found")
|
|
43
|
+
return output_path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_partner_output(relative_path: str) -> Path | None:
|
|
47
|
+
"""Resolve *relative_path* against partner scopes the caller may use.
|
|
48
|
+
|
|
49
|
+
Partner web chats run inside a synthetic workspace under
|
|
50
|
+
``data/partners/<id>/workspace``, so generated files are not beneath the
|
|
51
|
+
human caller's own ``data/users/<uid>`` tree. The public artifact URL shape
|
|
52
|
+
is still ``/files/outputs/<relative path>``, which means the download surface
|
|
53
|
+
has to search the caller's visible partner workspaces when their own
|
|
54
|
+
workspace misses (#1012).
|
|
55
|
+
|
|
56
|
+
The first-party chat flow already scopes the request to one conversation, so
|
|
57
|
+
the same relative path colliding across multiple partners is vanishingly
|
|
58
|
+
unlikely. Fail closed anyway: only a unique partner match is served.
|
|
59
|
+
"""
|
|
60
|
+
from deeptutor.modules.catalog import module_enabled
|
|
61
|
+
from deeptutor.services.workspace.context import current_workspace_id
|
|
62
|
+
|
|
63
|
+
if current_workspace_id() or not module_enabled("partners"):
|
|
64
|
+
return None
|
|
65
|
+
from deeptutor.services.partners.scope import partner_scope
|
|
66
|
+
|
|
67
|
+
matches: list[Path] = []
|
|
68
|
+
for partner in visible_partners():
|
|
69
|
+
partner_id = str(partner.get("partner_id") or "").strip()
|
|
70
|
+
if not partner_id:
|
|
71
|
+
continue
|
|
72
|
+
candidate = get_path_service_for_scope(
|
|
73
|
+
partner_scope(partner_id)
|
|
74
|
+
).resolve_public_output_path(relative_path)
|
|
75
|
+
if candidate is not None:
|
|
76
|
+
matches.append(candidate)
|
|
77
|
+
if len(matches) > 1:
|
|
78
|
+
return None
|
|
79
|
+
return matches[0] if matches else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/{output_path:path}", operation_id="read_output_get")
|
|
83
|
+
@router.head("/{output_path:path}", operation_id="read_output_head")
|
|
84
|
+
async def read_output(
|
|
85
|
+
output_path: str,
|
|
86
|
+
_auth: TokenPayload | None = Depends(require_auth),
|
|
87
|
+
) -> FileResponse:
|
|
88
|
+
"""Serve one allowlisted artifact from the authenticated user's reach."""
|
|
89
|
+
try:
|
|
90
|
+
path = _resolve_output(_request_path_service(), output_path)
|
|
91
|
+
except HTTPException:
|
|
92
|
+
path = _resolve_partner_output(output_path)
|
|
93
|
+
if path is None:
|
|
94
|
+
raise
|
|
95
|
+
if path.suffix.lower() == ".html" and "video_lesson" in path.parts:
|
|
96
|
+
return FileResponse(path, headers={"Content-Security-Policy": _SANDBOXED_PAGE_CSP})
|
|
97
|
+
return FileResponse(path)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Personas API Router
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
CRUD endpoints for user-authored PERSONA.md files stored under
|
|
6
|
+
``data/user/workspace/personas/<name>/PERSONA.md``.
|
|
7
|
+
|
|
8
|
+
Personas are behaviour/voice presets, not capability skills: admin-authored
|
|
9
|
+
personas are visible to every user as read-only deployment presets (no grant
|
|
10
|
+
mechanism — a persona carries no privileged workflow, only style guidance).
|
|
11
|
+
Users create and manage their own personas in their own workspace; a user
|
|
12
|
+
persona shadows an admin persona of the same name.
|
|
13
|
+
|
|
14
|
+
Mounted at ``/api/personas``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, HTTPException
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from deeptutor.multi_user.paths import get_admin_path_service
|
|
23
|
+
from deeptutor.services.i18n import t
|
|
24
|
+
from deeptutor.services.persona import (
|
|
25
|
+
InvalidPersonaNameError,
|
|
26
|
+
PersonaExistsError,
|
|
27
|
+
PersonaNotFoundError,
|
|
28
|
+
PersonaService,
|
|
29
|
+
get_persona_service,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CreatePersonaRequest(BaseModel):
|
|
36
|
+
name: str = Field(..., min_length=1, max_length=64)
|
|
37
|
+
description: str = ""
|
|
38
|
+
content: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UpdatePersonaRequest(BaseModel):
|
|
42
|
+
description: str | None = None
|
|
43
|
+
content: str | None = None
|
|
44
|
+
rename_to: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _admin_persona_service() -> PersonaService:
|
|
48
|
+
return PersonaService(root=get_admin_path_service().get_workspace_dir() / "personas")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _persona_presets(service: PersonaService) -> PersonaService | None:
|
|
52
|
+
"""The read-only deployment presets, unless ``service`` already holds them.
|
|
53
|
+
|
|
54
|
+
``None`` means the caller is reading the very directory the presets live in
|
|
55
|
+
— the admin on their account-level workspace — where merging would only
|
|
56
|
+
duplicate every row. Deciding that on ``user.is_admin`` instead took "the
|
|
57
|
+
caller is an admin" to mean "the caller is reading the preset directory",
|
|
58
|
+
which stopped being true once each workspace got its own personas
|
|
59
|
+
directory: inside an explicit workspace an admin saw nothing but Default
|
|
60
|
+
while a non-admin on the same workspace saw every preset (#1534).
|
|
61
|
+
"""
|
|
62
|
+
presets = _admin_persona_service()
|
|
63
|
+
if presets.root.resolve() == service.root.resolve():
|
|
64
|
+
return None
|
|
65
|
+
return presets
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/personas")
|
|
69
|
+
async def list_personas() -> dict[str, list[dict[str, object]]]:
|
|
70
|
+
service = get_persona_service()
|
|
71
|
+
own = [info.to_dict() for info in service.list_personas()]
|
|
72
|
+
presets = _persona_presets(service)
|
|
73
|
+
if presets is None:
|
|
74
|
+
return {"personas": own}
|
|
75
|
+
own_names = {item["name"] for item in own}
|
|
76
|
+
merged = list(own)
|
|
77
|
+
for preset in presets.list_personas():
|
|
78
|
+
if preset.name in own_names:
|
|
79
|
+
continue
|
|
80
|
+
entry = preset.to_dict()
|
|
81
|
+
entry.update({"source": "admin", "read_only": True})
|
|
82
|
+
merged.append(entry)
|
|
83
|
+
return {"personas": merged}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/personas/{name}")
|
|
87
|
+
async def get_persona(name: str) -> dict[str, object]:
|
|
88
|
+
service = get_persona_service()
|
|
89
|
+
try:
|
|
90
|
+
return service.get_detail(name).to_dict()
|
|
91
|
+
except PersonaNotFoundError:
|
|
92
|
+
pass
|
|
93
|
+
except InvalidPersonaNameError as exc:
|
|
94
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
95
|
+
|
|
96
|
+
presets = _persona_presets(service)
|
|
97
|
+
if presets is not None:
|
|
98
|
+
try:
|
|
99
|
+
detail = presets.get_detail(name).to_dict()
|
|
100
|
+
detail.update({"source": "admin", "read_only": True})
|
|
101
|
+
return detail
|
|
102
|
+
except (PersonaNotFoundError, InvalidPersonaNameError):
|
|
103
|
+
pass
|
|
104
|
+
raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.post("/personas")
|
|
108
|
+
async def create_persona(payload: CreatePersonaRequest) -> dict[str, object]:
|
|
109
|
+
service = get_persona_service()
|
|
110
|
+
try:
|
|
111
|
+
info = service.create(
|
|
112
|
+
name=payload.name,
|
|
113
|
+
description=payload.description,
|
|
114
|
+
content=payload.content,
|
|
115
|
+
)
|
|
116
|
+
return info.to_dict()
|
|
117
|
+
except PersonaExistsError:
|
|
118
|
+
raise HTTPException(
|
|
119
|
+
status_code=409,
|
|
120
|
+
detail=t("api.persona_already_exists", name=payload.name),
|
|
121
|
+
)
|
|
122
|
+
except InvalidPersonaNameError as exc:
|
|
123
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.put("/personas/{name}")
|
|
127
|
+
async def update_persona(name: str, payload: UpdatePersonaRequest) -> dict[str, object]:
|
|
128
|
+
service = get_persona_service()
|
|
129
|
+
try:
|
|
130
|
+
info = service.update(
|
|
131
|
+
name,
|
|
132
|
+
description=payload.description,
|
|
133
|
+
content=payload.content,
|
|
134
|
+
rename_to=payload.rename_to,
|
|
135
|
+
)
|
|
136
|
+
return info.to_dict()
|
|
137
|
+
except PersonaNotFoundError:
|
|
138
|
+
raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
|
|
139
|
+
except PersonaExistsError as exc:
|
|
140
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
141
|
+
except InvalidPersonaNameError as exc:
|
|
142
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.delete("/personas/{name}")
|
|
146
|
+
async def delete_persona(name: str) -> dict[str, str]:
|
|
147
|
+
service = get_persona_service()
|
|
148
|
+
try:
|
|
149
|
+
service.delete(name)
|
|
150
|
+
return {"status": "deleted", "name": name}
|
|
151
|
+
except PersonaNotFoundError:
|
|
152
|
+
raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
|
|
153
|
+
except InvalidPersonaNameError as exc:
|
|
154
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Authenticated introspection and managed plugin HTTP route dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from deeptutor.api.routers.auth import AUTH_COOKIE_NAME, require_admin, require_auth
|
|
16
|
+
from deeptutor.plugins.catalog import load_catalog
|
|
17
|
+
from deeptutor.plugins.distribution import CatalogInstallManager, PluginDistributionError
|
|
18
|
+
from deeptutor.plugins.frontend import (
|
|
19
|
+
FrontendPageError,
|
|
20
|
+
load_frontend_page_manifest,
|
|
21
|
+
resolve_frontend_page_asset,
|
|
22
|
+
)
|
|
23
|
+
from deeptutor.plugins.lifecycle import PluginLifecycleError, PluginLifecycleManager
|
|
24
|
+
from deeptutor.plugins.manifest import PluginExtension
|
|
25
|
+
from deeptutor.plugins.registry import (
|
|
26
|
+
PluginInstallation,
|
|
27
|
+
PluginRecord,
|
|
28
|
+
PluginRegistry,
|
|
29
|
+
PluginStateError,
|
|
30
|
+
)
|
|
31
|
+
from deeptutor.plugins.runtime import (
|
|
32
|
+
PluginHttpResponse,
|
|
33
|
+
PluginRuntimeError,
|
|
34
|
+
PluginWorkerHttpRoute,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
router = APIRouter()
|
|
39
|
+
_registry_factory = PluginRegistry
|
|
40
|
+
_MAX_JSON_BODY_BYTES = 1024 * 1024
|
|
41
|
+
_FRONTEND_CSP = (
|
|
42
|
+
"default-src 'none'; "
|
|
43
|
+
"script-src 'self'; "
|
|
44
|
+
"style-src 'self' 'unsafe-inline'; "
|
|
45
|
+
"img-src 'self' data: blob:; "
|
|
46
|
+
"font-src 'self' data:; "
|
|
47
|
+
"media-src 'self' data: blob:; "
|
|
48
|
+
"connect-src 'none'; frame-src 'none'; object-src 'none'; "
|
|
49
|
+
"base-uri 'none'; form-action 'none'; frame-ancestors 'self'; "
|
|
50
|
+
"sandbox allow-scripts"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _require_authenticated(request: Request):
|
|
55
|
+
return await require_auth(
|
|
56
|
+
authorization=request.headers.get("Authorization"),
|
|
57
|
+
dt_token=request.cookies.get(AUTH_COOKIE_NAME),
|
|
58
|
+
request=request,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _require_admin(request: Request):
|
|
63
|
+
return await require_admin(
|
|
64
|
+
payload=await _require_authenticated(request),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _managed_records(registry: PluginRegistry) -> list[PluginRecord]:
|
|
69
|
+
return [
|
|
70
|
+
record
|
|
71
|
+
for record in registry.list_plugins(include_catalog=False)
|
|
72
|
+
if record.status == "enabled"
|
|
73
|
+
and record.distribution == "managed"
|
|
74
|
+
and record.manifest is not None
|
|
75
|
+
and record.installation is not None
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extensions_payload(records: list[PluginRecord]) -> list[dict[str, Any]]:
|
|
80
|
+
result: list[dict[str, Any]] = []
|
|
81
|
+
for record in records:
|
|
82
|
+
if record.manifest is None:
|
|
83
|
+
continue
|
|
84
|
+
extensions: list[dict[str, Any]] = []
|
|
85
|
+
for extension in record.manifest.extensions:
|
|
86
|
+
payload = extension.to_dict()
|
|
87
|
+
if extension.type == "frontend_page" and extension.manifest:
|
|
88
|
+
payload["entry_url"] = f"/api/plugins/{record.id}/pages/{extension.id}/"
|
|
89
|
+
extensions.append(payload)
|
|
90
|
+
result.append(
|
|
91
|
+
{
|
|
92
|
+
"id": record.id,
|
|
93
|
+
"name": record.name,
|
|
94
|
+
"version": record.version,
|
|
95
|
+
"extensions": extensions,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _find_route(
|
|
102
|
+
*,
|
|
103
|
+
plugin_id: str,
|
|
104
|
+
plugin_path: str,
|
|
105
|
+
) -> tuple[PluginRecord, PluginExtension, PluginInstallation] | None:
|
|
106
|
+
normalized_path = _normalize_plugin_path(plugin_path)
|
|
107
|
+
if normalized_path is None:
|
|
108
|
+
return None
|
|
109
|
+
for record in _managed_records(_registry_factory()):
|
|
110
|
+
if record.id != plugin_id or record.manifest is None or record.installation is None:
|
|
111
|
+
continue
|
|
112
|
+
for extension in record.manifest.extensions:
|
|
113
|
+
if extension.type == "http_route" and extension.path == normalized_path:
|
|
114
|
+
return record, extension, record.installation
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_frontend_page(
|
|
119
|
+
*,
|
|
120
|
+
plugin_id: str,
|
|
121
|
+
extension_id: str,
|
|
122
|
+
) -> tuple[PluginRecord, PluginExtension, PluginInstallation, Path] | None:
|
|
123
|
+
for record in _managed_records(_registry_factory()):
|
|
124
|
+
if record.id != plugin_id or record.manifest is None or record.installation is None:
|
|
125
|
+
continue
|
|
126
|
+
if record.installation.package_path is None:
|
|
127
|
+
continue
|
|
128
|
+
for extension in record.manifest.extensions:
|
|
129
|
+
if extension.type == "frontend_page" and extension.id == extension_id:
|
|
130
|
+
return record, extension, record.installation, record.installation.package_path
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _normalize_plugin_path(value: str) -> str | None:
|
|
135
|
+
if not value or "\x00" in value or "%" in value or "\\" in value:
|
|
136
|
+
return None
|
|
137
|
+
return "/" + value.strip("/")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _authenticate_plugin_route(request: Request) -> None:
|
|
141
|
+
payload = await _require_authenticated(request)
|
|
142
|
+
matched = _find_route(
|
|
143
|
+
plugin_id=request.path_params["plugin_id"],
|
|
144
|
+
plugin_path=request.path_params["plugin_path"],
|
|
145
|
+
)
|
|
146
|
+
if matched is None:
|
|
147
|
+
return
|
|
148
|
+
request.state.plugin_route = matched
|
|
149
|
+
policy = matched[1].auth
|
|
150
|
+
if policy == "admin":
|
|
151
|
+
await require_admin(payload=payload)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _authenticate_plugin_page(request: Request) -> None:
|
|
155
|
+
payload = await _require_authenticated(request)
|
|
156
|
+
matched = _find_frontend_page(
|
|
157
|
+
plugin_id=request.path_params["plugin_id"],
|
|
158
|
+
extension_id=request.path_params["extension_id"],
|
|
159
|
+
)
|
|
160
|
+
if matched is None:
|
|
161
|
+
return
|
|
162
|
+
request.state.plugin_page = matched
|
|
163
|
+
policy = matched[1].auth
|
|
164
|
+
if policy == "admin":
|
|
165
|
+
await require_admin(payload=payload)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.get("/extensions", dependencies=[Depends(_require_authenticated)])
|
|
169
|
+
def list_extensions() -> dict[str, Any]:
|
|
170
|
+
return {"plugins": _extensions_payload(_managed_records(_registry_factory()))}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@router.get(
|
|
174
|
+
"/{plugin_id}/pages/{extension_id}",
|
|
175
|
+
dependencies=[Depends(_authenticate_plugin_page)],
|
|
176
|
+
)
|
|
177
|
+
def redirect_plugin_frontend_page(request: Request) -> RedirectResponse:
|
|
178
|
+
matched = getattr(request.state, "plugin_page", None)
|
|
179
|
+
if matched is None:
|
|
180
|
+
raise HTTPException(status_code=404, detail="Plugin page not found")
|
|
181
|
+
return RedirectResponse(url=f"{request.url.path}/", status_code=307)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.get(
|
|
185
|
+
"/{plugin_id}/pages/{extension_id}/",
|
|
186
|
+
dependencies=[Depends(_authenticate_plugin_page)],
|
|
187
|
+
)
|
|
188
|
+
def get_plugin_frontend_page(request: Request) -> FileResponse:
|
|
189
|
+
matched = getattr(request.state, "plugin_page", None)
|
|
190
|
+
if matched is None:
|
|
191
|
+
raise HTTPException(status_code=404, detail="Plugin page not found")
|
|
192
|
+
_, extension, _, package_path = matched
|
|
193
|
+
if not extension.manifest:
|
|
194
|
+
raise HTTPException(status_code=404, detail="Plugin page not found")
|
|
195
|
+
try:
|
|
196
|
+
page = load_frontend_page_manifest(package_path / extension.manifest)
|
|
197
|
+
path = resolve_frontend_page_asset(page, package_path, page.entry)
|
|
198
|
+
except FrontendPageError as exc:
|
|
199
|
+
logger.warning(
|
|
200
|
+
"Managed frontend page %s/%s failed validation",
|
|
201
|
+
matched[0].id,
|
|
202
|
+
extension.id,
|
|
203
|
+
exc_info=exc,
|
|
204
|
+
)
|
|
205
|
+
raise HTTPException(status_code=404, detail="Plugin page not found") from exc
|
|
206
|
+
return _frontend_file_response(path)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@router.get(
|
|
210
|
+
"/{plugin_id}/pages/{extension_id}/assets/{asset_path:path}",
|
|
211
|
+
dependencies=[Depends(_authenticate_plugin_page)],
|
|
212
|
+
)
|
|
213
|
+
def get_plugin_frontend_asset(request: Request) -> FileResponse:
|
|
214
|
+
matched = getattr(request.state, "plugin_page", None)
|
|
215
|
+
if matched is None:
|
|
216
|
+
raise HTTPException(status_code=404, detail="Plugin page not found")
|
|
217
|
+
_, extension, _, package_path = matched
|
|
218
|
+
if not extension.manifest:
|
|
219
|
+
raise HTTPException(status_code=404, detail="Plugin page not found")
|
|
220
|
+
requested = request.path_params["asset_path"]
|
|
221
|
+
try:
|
|
222
|
+
page = load_frontend_page_manifest(package_path / extension.manifest)
|
|
223
|
+
path = resolve_frontend_page_asset(page, package_path, requested)
|
|
224
|
+
except FrontendPageError as exc:
|
|
225
|
+
logger.warning(
|
|
226
|
+
"Managed frontend asset %s/%s failed validation",
|
|
227
|
+
matched[0].id,
|
|
228
|
+
extension.id,
|
|
229
|
+
exc_info=exc,
|
|
230
|
+
)
|
|
231
|
+
raise HTTPException(status_code=404, detail="Plugin page not found") from exc
|
|
232
|
+
return _frontend_file_response(path, asset=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def dispatch_plugin_route(request: Request) -> Response:
|
|
236
|
+
matched = getattr(request.state, "plugin_route", None)
|
|
237
|
+
if matched is None:
|
|
238
|
+
raise HTTPException(status_code=404, detail="Plugin route not found")
|
|
239
|
+
record, extension, installation = matched
|
|
240
|
+
if request.method not in extension.methods:
|
|
241
|
+
raise HTTPException(
|
|
242
|
+
status_code=405,
|
|
243
|
+
detail="Method not allowed",
|
|
244
|
+
headers={"Allow": ", ".join(extension.methods)},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
chunks = []
|
|
248
|
+
size = 0
|
|
249
|
+
async for chunk in request.stream():
|
|
250
|
+
size += len(chunk)
|
|
251
|
+
if size > _MAX_JSON_BODY_BYTES:
|
|
252
|
+
raise HTTPException(status_code=413, detail="Plugin request body is too large")
|
|
253
|
+
chunks.append(chunk)
|
|
254
|
+
raw_body = b"".join(chunks)
|
|
255
|
+
body = None
|
|
256
|
+
if raw_body:
|
|
257
|
+
try:
|
|
258
|
+
body = json.loads(raw_body)
|
|
259
|
+
except ValueError as exc:
|
|
260
|
+
raise HTTPException(status_code=400, detail="Request body must be JSON") from exc
|
|
261
|
+
|
|
262
|
+
query: dict[str, tuple[str, ...]] = {
|
|
263
|
+
name: tuple(request.query_params.getlist(name)) for name in request.query_params.keys()
|
|
264
|
+
}
|
|
265
|
+
worker = PluginWorkerHttpRoute(
|
|
266
|
+
manifest=record.manifest,
|
|
267
|
+
installation=installation,
|
|
268
|
+
extension_id=extension.id,
|
|
269
|
+
registry=_registry_factory(),
|
|
270
|
+
)
|
|
271
|
+
try:
|
|
272
|
+
result = await asyncio.to_thread(
|
|
273
|
+
worker.handle,
|
|
274
|
+
method=request.method,
|
|
275
|
+
path=extension.path,
|
|
276
|
+
query=query,
|
|
277
|
+
body=body,
|
|
278
|
+
)
|
|
279
|
+
except PluginRuntimeError as exc:
|
|
280
|
+
logger.warning(
|
|
281
|
+
"Managed HTTP route %s/%s failed its worker contract",
|
|
282
|
+
record.id,
|
|
283
|
+
extension.id,
|
|
284
|
+
exc_info=exc,
|
|
285
|
+
)
|
|
286
|
+
raise HTTPException(status_code=500, detail="Plugin route failed") from exc
|
|
287
|
+
return _http_response(result)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class ApprovalRequest(BaseModel):
|
|
291
|
+
snapshot_digest: str = Field(pattern="^[0-9a-f]{64}$")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class InstallRequest(BaseModel):
|
|
295
|
+
version: str = Field(default="latest", max_length=64)
|
|
296
|
+
allow_deprecated: bool = False
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@router.get("/catalog", dependencies=[Depends(_require_authenticated)])
|
|
300
|
+
def plugin_catalog():
|
|
301
|
+
return {"entries": [entry.to_dict() for entry in load_catalog()]}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@router.get("/state", dependencies=[Depends(_require_authenticated)])
|
|
305
|
+
def plugin_state():
|
|
306
|
+
return {"plugins": [record.to_dict() for record in _registry_factory().list_plugins()]}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.get("/{plugin_id}/info", dependencies=[Depends(_require_authenticated)])
|
|
310
|
+
def plugin_info(plugin_id: str):
|
|
311
|
+
record = _registry_factory().get_plugin(plugin_id)
|
|
312
|
+
if record is None:
|
|
313
|
+
raise HTTPException(404, "Plugin not found")
|
|
314
|
+
return record.to_dict()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@router.post("/{plugin_id}/approve", dependencies=[Depends(_require_admin)])
|
|
318
|
+
def approve_plugin(plugin_id: str, payload: ApprovalRequest):
|
|
319
|
+
try:
|
|
320
|
+
return (
|
|
321
|
+
_registry_factory()
|
|
322
|
+
.approve(plugin_id, expected_digest=payload.snapshot_digest)
|
|
323
|
+
.to_dict()
|
|
324
|
+
)
|
|
325
|
+
except PluginStateError as exc:
|
|
326
|
+
raise HTTPException(409, str(exc)) from exc
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@router.post("/{plugin_id}/enable", dependencies=[Depends(_require_admin)])
|
|
330
|
+
def enable_plugin(plugin_id: str):
|
|
331
|
+
try:
|
|
332
|
+
return _registry_factory().set_enabled(plugin_id, True).to_dict()
|
|
333
|
+
except PluginStateError as exc:
|
|
334
|
+
raise HTTPException(409, str(exc)) from exc
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@router.post("/{plugin_id}/disable", dependencies=[Depends(_require_admin)])
|
|
338
|
+
def disable_plugin(plugin_id: str):
|
|
339
|
+
try:
|
|
340
|
+
return _registry_factory().set_enabled(plugin_id, False).to_dict()
|
|
341
|
+
except PluginStateError as exc:
|
|
342
|
+
raise HTTPException(409, str(exc)) from exc
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@router.post("/{plugin_id}/install", dependencies=[Depends(_require_admin)])
|
|
346
|
+
def install_plugin(plugin_id: str, payload: InstallRequest):
|
|
347
|
+
try:
|
|
348
|
+
result = CatalogInstallManager(
|
|
349
|
+
lifecycle_factory=lambda: PluginLifecycleManager(registry=_registry_factory())
|
|
350
|
+
).install(plugin_id, version=payload.version, allow_deprecated=payload.allow_deprecated)
|
|
351
|
+
return {
|
|
352
|
+
"id": result.lifecycle.plugin_id,
|
|
353
|
+
"version": result.lifecycle.version,
|
|
354
|
+
"action": result.lifecycle.action,
|
|
355
|
+
"approval_required": True,
|
|
356
|
+
}
|
|
357
|
+
except (PluginStateError, PluginLifecycleError, PluginDistributionError) as exc:
|
|
358
|
+
raise HTTPException(409, str(exc)) from exc
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@router.post("/{plugin_id}/rollback", dependencies=[Depends(_require_admin)])
|
|
362
|
+
def rollback_plugin(plugin_id: str):
|
|
363
|
+
try:
|
|
364
|
+
result = PluginLifecycleManager(registry=_registry_factory()).rollback(plugin_id)
|
|
365
|
+
return {"id": result.plugin_id, "version": result.version, "approval_required": True}
|
|
366
|
+
except (PluginStateError, PluginLifecycleError) as exc:
|
|
367
|
+
raise HTTPException(409, str(exc)) from exc
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@router.delete("/{plugin_id}/installation", dependencies=[Depends(_require_admin)])
|
|
371
|
+
def uninstall_plugin(plugin_id: str):
|
|
372
|
+
try:
|
|
373
|
+
result = PluginLifecycleManager(registry=_registry_factory()).uninstall(plugin_id)
|
|
374
|
+
return {"id": result.plugin_id, "action": result.action}
|
|
375
|
+
except (PluginStateError, PluginLifecycleError) as exc:
|
|
376
|
+
raise HTTPException(409, str(exc)) from exc
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
for _method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
|
380
|
+
router.add_api_route(
|
|
381
|
+
"/{plugin_id}/{plugin_path:path}",
|
|
382
|
+
dispatch_plugin_route,
|
|
383
|
+
methods=[_method],
|
|
384
|
+
operation_id=f"dispatch_plugin_route_{_method.lower()}",
|
|
385
|
+
dependencies=[Depends(_authenticate_plugin_route)],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _http_response(result: PluginHttpResponse) -> Response:
|
|
390
|
+
if result.status == 204:
|
|
391
|
+
return Response(status_code=204, headers=dict(result.headers or {}))
|
|
392
|
+
return JSONResponse(
|
|
393
|
+
status_code=result.status,
|
|
394
|
+
content=result.body,
|
|
395
|
+
headers=dict(result.headers or {}),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _frontend_file_response(path: Path, *, asset: bool = False) -> FileResponse:
|
|
400
|
+
media_type = "text/html; charset=utf-8" if path.suffix.lower() == ".html" else None
|
|
401
|
+
return FileResponse(
|
|
402
|
+
path,
|
|
403
|
+
media_type=media_type,
|
|
404
|
+
headers={
|
|
405
|
+
"Cache-Control": "no-store",
|
|
406
|
+
"Vary": "Authorization, Cookie",
|
|
407
|
+
"Content-Security-Policy": _FRONTEND_CSP,
|
|
408
|
+
"Cross-Origin-Resource-Policy": "cross-origin" if asset else "same-origin",
|
|
409
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
410
|
+
"Referrer-Policy": "no-referrer",
|
|
411
|
+
"X-Content-Type-Options": "nosniff",
|
|
412
|
+
},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
__all__ = ["router"]
|