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.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,371 @@
1
+ """Content-workspace settings and authenticated presentation delivery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status
9
+ from fastapi.responses import FileResponse
10
+ from pydantic import BaseModel, Field
11
+
12
+ from deeptutor.api.routers.auth import require_auth
13
+ from deeptutor.multi_user.partner_access import visible_partners
14
+ from deeptutor.multi_user.paths import user_context
15
+ from deeptutor.services.auth import TokenPayload
16
+ from deeptutor.services.workspace import (
17
+ WorkspaceError,
18
+ WorkspaceItem,
19
+ get_content_workspace_service,
20
+ )
21
+ from deeptutor.services.workspace.models import MAX_WORKSPACE_INSTRUCTIONS_CHARS
22
+ from deeptutor.services.workspace.resources import WorkspaceResources
23
+
24
+ settings_router = APIRouter()
25
+ files_router = APIRouter()
26
+
27
+
28
+ class WorkspacePathPayload(BaseModel):
29
+ path: str | None = None
30
+
31
+
32
+ class CreateWorkspacePayload(BaseModel):
33
+ instructions: str | None = Field(default=None, max_length=MAX_WORKSPACE_INSTRUCTIONS_CHARS)
34
+ resources: WorkspaceResources | None = None
35
+ name: str = Field(min_length=1, max_length=100)
36
+ path: str | None = None
37
+
38
+
39
+ class UpdateWorkspacePayload(BaseModel):
40
+ instructions: str | None = Field(default=None, max_length=MAX_WORKSPACE_INSTRUCTIONS_CHARS)
41
+ resources: WorkspaceResources | None = None
42
+ name: str | None = Field(default=None, min_length=1, max_length=100)
43
+ archived: bool | None = None
44
+
45
+
46
+ class DataMigrationPayload(BaseModel):
47
+ source_workspace_id: str = ""
48
+ target_workspace_id: str = ""
49
+ features: list[str] = Field(min_length=1)
50
+ include_historical: bool = False
51
+
52
+
53
+ class KnowledgeMovePayload(BaseModel):
54
+ source_id: str = Field(min_length=1)
55
+ target_workspace_id: str = ""
56
+
57
+
58
+ async def _data_operation(function, *args, **kwargs):
59
+ _assert_migration_access()
60
+ task = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
61
+ task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
62
+ try:
63
+ return await asyncio.shield(task)
64
+ except (WorkspaceError, OSError, ValueError) as exc:
65
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
66
+
67
+
68
+ @settings_router.post("/knowledge-bases/preview-move")
69
+ async def preview_knowledge_move(payload: KnowledgeMovePayload) -> dict:
70
+ from deeptutor.services.workspace.kb_move import preview_kb_move
71
+
72
+ return await _data_operation(preview_kb_move, payload.source_id, payload.target_workspace_id)
73
+
74
+
75
+ @settings_router.post("/knowledge-bases/move")
76
+ async def move_knowledge_base(payload: KnowledgeMovePayload) -> dict:
77
+ from deeptutor.services.workspace.kb_move import move_kb
78
+
79
+ return await _data_operation(move_kb, payload.source_id, payload.target_workspace_id)
80
+
81
+
82
+ def _assert_migration_access():
83
+ from deeptutor.multi_user.learning_access import current_learning_policy
84
+
85
+ if current_learning_policy() is not None:
86
+ raise HTTPException(
87
+ status_code=403,
88
+ detail="Data migration and export are managed by the guardian for this learning account.",
89
+ )
90
+
91
+
92
+ @settings_router.get("/data/discover")
93
+ async def discover_workspace_data(source_workspace_id: str = "") -> dict:
94
+ from deeptutor.services.workspace.data_migration import discover
95
+
96
+ return await _data_operation(discover, source_workspace_id)
97
+
98
+
99
+ @settings_router.post("/data/preview")
100
+ async def preview_workspace_data(payload: DataMigrationPayload) -> dict:
101
+ from deeptutor.services.workspace.data_migration import preview
102
+
103
+ return await _data_operation(
104
+ preview, payload.source_workspace_id, payload.target_workspace_id, payload.features
105
+ )
106
+
107
+
108
+ @settings_router.post("/data/migrate")
109
+ async def migrate_workspace_data(payload: DataMigrationPayload) -> dict:
110
+ from deeptutor.services.workspace.data_migration import migrate_data
111
+
112
+ return await _data_operation(
113
+ migrate_data, payload.source_workspace_id, payload.target_workspace_id, payload.features
114
+ )
115
+
116
+
117
+ @settings_router.post("/data/export")
118
+ async def export_workspace_data(payload: DataMigrationPayload) -> dict:
119
+ from deeptutor.services.workspace.data_migration import export_data
120
+
121
+ return await _data_operation(
122
+ export_data,
123
+ payload.source_workspace_id,
124
+ payload.features,
125
+ include_historical=payload.include_historical,
126
+ )
127
+
128
+
129
+ @settings_router.get("/data/operations")
130
+ async def workspace_data_operations() -> dict:
131
+ _assert_migration_access()
132
+ from deeptutor.services.workspace.data_migration import operations
133
+
134
+ return {"operations": await asyncio.to_thread(operations)}
135
+
136
+
137
+ @settings_router.post("/data/operations/{operation_id}/recover")
138
+ async def recover_workspace_migration(operation_id: str) -> dict:
139
+ from deeptutor.services.workspace.data_migration import recover_operation
140
+
141
+ return await _data_operation(recover_operation, operation_id)
142
+
143
+
144
+ @settings_router.get("/data/exports/{operation_id}")
145
+ async def download_workspace_export(operation_id: str) -> FileResponse:
146
+ _assert_migration_access()
147
+ from deeptutor.services.workspace.data_migration import export_path
148
+
149
+ try:
150
+ return FileResponse(
151
+ export_path(operation_id),
152
+ media_type="application/zip",
153
+ filename=f"deeptutor-data-{operation_id[:8]}.zip",
154
+ )
155
+ except WorkspaceError as exc:
156
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
157
+
158
+
159
+ @settings_router.get("/registrations")
160
+ async def list_workspaces() -> dict:
161
+ return get_content_workspace_service().describe_catalog()
162
+
163
+
164
+ class WorkspaceMigrationPayload(BaseModel):
165
+ path: str = Field(min_length=1)
166
+
167
+
168
+ async def _migrate_workspaces(path: str, workspace_id: str | None = None) -> dict:
169
+ from deeptutor.services.session import get_session_store
170
+
171
+ service = get_content_workspace_service()
172
+
173
+ async def perform() -> dict:
174
+ from deeptutor.services.workspace.activity import data_activity
175
+ from deeptutor.services.workspace.data_migration import assert_no_pending_recovery
176
+
177
+ with data_activity(exclusive=True):
178
+ assert_no_pending_recovery(reject_unreadable=True)
179
+ return await migrate_location()
180
+
181
+ async def migrate_location() -> dict:
182
+ with service.maintenance():
183
+ active = await get_session_store().list_nonterminal_turns()
184
+ if active:
185
+ raise WorkspaceError(
186
+ "Wait for running conversations to finish before migrating workspace storage."
187
+ )
188
+ if workspace_id is None:
189
+ return await asyncio.to_thread(service.migrate_root, path)
190
+ return await asyncio.to_thread(service.migrate_workspace, workspace_id, path)
191
+
192
+ # A disconnected browser must not abandon a copy midway or release the
193
+ # migration guard while its worker is still copying files.
194
+ task = asyncio.create_task(perform())
195
+ # Retrieve a late error even if the requesting client has disconnected.
196
+ task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
197
+ try:
198
+ return await asyncio.shield(task)
199
+ except (WorkspaceError, OSError, ValueError) as exc:
200
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
201
+
202
+
203
+ @settings_router.post("/registrations/migrate-root")
204
+ async def migrate_workspace_root(payload: WorkspaceMigrationPayload) -> dict:
205
+ return await _migrate_workspaces(payload.path)
206
+
207
+
208
+ @settings_router.post("/registrations/{workspace_id}/migrate")
209
+ async def migrate_registered_workspace(
210
+ workspace_id: str, payload: WorkspaceMigrationPayload
211
+ ) -> dict:
212
+ return await _migrate_workspaces(payload.path, workspace_id)
213
+
214
+
215
+ @settings_router.get("/registrations/system-snapshot")
216
+ @settings_router.post("/registrations/system-snapshot")
217
+ async def system_workspace_snapshot() -> dict:
218
+ try:
219
+ return get_content_workspace_service().refresh_system_snapshot()
220
+ except (WorkspaceError, OSError, ValueError) as exc:
221
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
222
+
223
+
224
+ @settings_router.get("/resource-usage")
225
+ async def workspace_resource_usage(kind: str, resource_id: str, skill_workspace: str = "") -> dict:
226
+ from deeptutor.services.workspace.resources import resource_usage
227
+
228
+ try:
229
+ return {"workspaces": resource_usage(kind, resource_id, skill_workspace=skill_workspace)}
230
+ except WorkspaceError as exc:
231
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
232
+
233
+
234
+ @settings_router.get("/resources")
235
+ async def workspace_resource_catalog(workspace_id: str = "") -> dict:
236
+ from deeptutor.services.workspace.resources import resource_catalog
237
+
238
+ try:
239
+ return await asyncio.to_thread(resource_catalog, workspace_id=workspace_id)
240
+ except WorkspaceError as exc:
241
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
242
+
243
+
244
+ @settings_router.post("/registrations")
245
+ async def create_workspace(payload: CreateWorkspacePayload) -> dict:
246
+ try:
247
+ workspace = get_content_workspace_service().create_workspace(
248
+ payload.name,
249
+ payload.path,
250
+ resources=payload.resources.model_dump() if payload.resources else None,
251
+ instructions=payload.instructions,
252
+ )
253
+ except WorkspaceError as exc:
254
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
255
+ return {"workspace": workspace}
256
+
257
+
258
+ @settings_router.patch("/registrations/{workspace_id}")
259
+ async def update_registered_workspace(workspace_id: str, payload: UpdateWorkspacePayload) -> dict:
260
+ try:
261
+ from deeptutor.services.workspace.activity import data_activity
262
+ from deeptutor.services.workspace.data_migration import assert_no_pending_recovery
263
+
264
+ # Instruction edits are atomic metadata updates. A running turn owns
265
+ # its admitted snapshot, so these edits can coexist with its data lease.
266
+ changes = payload.model_dump(exclude_unset=True)
267
+ with data_activity(exclusive=set(changes) != {"instructions"}):
268
+ assert_no_pending_recovery(reject_unreadable=True)
269
+ workspace = get_content_workspace_service().update_workspace(workspace_id, **changes)
270
+ except WorkspaceError as exc:
271
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
272
+ return {"workspace": workspace}
273
+
274
+
275
+ @settings_router.get("")
276
+ async def get_workspace_settings() -> dict:
277
+ return get_content_workspace_service().describe_current()
278
+
279
+
280
+ @settings_router.post("/validate")
281
+ async def validate_workspace(payload: WorkspacePathPayload) -> dict:
282
+ return get_content_workspace_service().validate(payload.path)
283
+
284
+
285
+ @settings_router.put("")
286
+ async def update_workspace(payload: WorkspacePathPayload) -> dict:
287
+ service = get_content_workspace_service()
288
+ try:
289
+ service.set_workspace(payload.path)
290
+ except WorkspaceError as exc:
291
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
292
+ return service.describe_current()
293
+
294
+
295
+ def _resolve_partner_item(
296
+ workspace_id: str, workspace_item_id: str
297
+ ) -> tuple[Path, WorkspaceItem] | None:
298
+ """Resolve a presentation one of the caller's partners published.
299
+
300
+ A partner web chat runs inside a synthetic workspace under
301
+ ``data/partners/<id>/workspace``, so ``workspace_present`` writes its
302
+ manifest to that scope's private presentation store. The human caller's own
303
+ bindings never name that workspace, ``binding_by_id`` refuses it, and the
304
+ attachment sitting in the transcript 404s on click (#1267).
305
+
306
+ ``/files/outputs`` grew the same reach in #1012 — see
307
+ ``_resolve_partner_output`` — and this is the other artifact URL shape. It
308
+ does not need that endpoint's unique-match rule: a workspace id is derived
309
+ from the owning user id together with the physical root and re-derived
310
+ before a stored row is trusted, so a hit inside a partner scope belongs to
311
+ that partner by construction, where a relative output path can collide.
312
+ """
313
+ from deeptutor.services.workspace.context import current_workspace_id
314
+
315
+ if current_workspace_id():
316
+ return None
317
+ for partner in visible_partners():
318
+ partner_id = str(partner.get("partner_id") or "").strip()
319
+ if not partner_id:
320
+ continue
321
+ from deeptutor.services.partners.scope import partner_user
322
+
323
+ with user_context(partner_user(partner_id)):
324
+ try:
325
+ return get_content_workspace_service().resolve_published_item(
326
+ workspace_id, workspace_item_id
327
+ )
328
+ except WorkspaceError:
329
+ continue
330
+ return None
331
+
332
+
333
+ @files_router.get("/{workspace_id}/{workspace_item_id}", operation_id="read_workspace_item_get")
334
+ @files_router.head("/{workspace_id}/{workspace_item_id}", operation_id="read_workspace_item_head")
335
+ async def read_workspace_item(
336
+ workspace_id: str,
337
+ workspace_item_id: str,
338
+ _auth: TokenPayload | None = Depends(require_auth),
339
+ ) -> FileResponse:
340
+ try:
341
+ path, item = get_content_workspace_service().resolve_published_item(
342
+ workspace_id, workspace_item_id
343
+ )
344
+ except WorkspaceError as exc:
345
+ resolved = _resolve_partner_item(workspace_id, workspace_item_id)
346
+ if resolved is None:
347
+ raise HTTPException(
348
+ status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
349
+ ) from exc
350
+ path, item = resolved
351
+ return FileResponse(
352
+ path,
353
+ media_type=item.mime_type,
354
+ filename=item.filename,
355
+ content_disposition_type="inline",
356
+ headers={
357
+ "ETag": f'"{item.sha256}"',
358
+ "Cache-Control": "private, max-age=31536000, immutable",
359
+ "X-Content-Type-Options": "nosniff",
360
+ # Presented files are user/model-controlled. Opening an HTML or
361
+ # SVG snapshot directly on the app origin must never execute it
362
+ # with access to DeepTutor's authenticated origin.
363
+ "Content-Security-Policy": (
364
+ "sandbox; default-src 'none'; img-src data: blob:; "
365
+ "media-src 'self' blob:; style-src 'unsafe-inline'"
366
+ ),
367
+ },
368
+ )
369
+
370
+
371
+ __all__ = ["files_router", "settings_router"]
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Uvicorn Server Startup Script
4
+ Uses Python API instead of command line to avoid Windows path parsing issues.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ from pathlib import Path
10
+ import sys
11
+
12
+ from deeptutor.runtime.home import get_runtime_home
13
+
14
+ # Windows: uvicorn defaults to SelectorEventLoop which does not support
15
+ # asyncio.create_subprocess_exec. Switch to ProactorEventLoop so that
16
+ # child-process APIs (used by Math Animator renderer, etc.) work correctly.
17
+ if sys.platform == "win32":
18
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
19
+
20
+ import uvicorn
21
+
22
+ # Force unbuffered output
23
+ os.environ["PYTHONUNBUFFERED"] = "1"
24
+ if hasattr(sys.stdout, "reconfigure"):
25
+ sys.stdout.reconfigure(line_buffering=True, encoding="utf-8", errors="replace")
26
+ if hasattr(sys.stderr, "reconfigure"):
27
+ sys.stderr.reconfigure(line_buffering=True, encoding="utf-8", errors="replace")
28
+
29
+
30
+ def main() -> None:
31
+ # Runtime workspace root owns data/user/settings and generated outputs.
32
+ project_root = get_runtime_home()
33
+ os.chdir(str(project_root))
34
+
35
+ # Get port from configuration
36
+ from deeptutor.logging import configure_logging
37
+ from deeptutor.runtime.mode import RunMode, set_mode
38
+ from deeptutor.services.config import (
39
+ HTTP_KEEP_ALIVE_TIMEOUT,
40
+ get_ws_max_size,
41
+ load_system_settings,
42
+ )
43
+ from deeptutor.services.setup import get_backend_port
44
+
45
+ set_mode(RunMode.SERVER)
46
+ configure_logging()
47
+ backend_port = get_backend_port(project_root)
48
+
49
+ # Configure reload_excludes to skip directories that shouldn't trigger reloads
50
+ # Use absolute paths to ensure they're properly resolved
51
+ reload_excludes = [
52
+ str(project_root / "venv"), # Virtual environment
53
+ str(project_root / ".venv"), # Virtual environment (alternative name)
54
+ str(project_root / "data"), # Data directory (includes knowledge_bases, user data, logs)
55
+ str(project_root / "node_modules"), # Node modules (if any at root)
56
+ str(project_root / "web" / "node_modules"), # Web node modules
57
+ str(project_root / "web" / ".next"), # Next.js build
58
+ str(project_root / ".git"), # Git directory
59
+ str(project_root / "scripts"), # Scripts directory - don't reload on launcher changes
60
+ ]
61
+
62
+ # Filter out non-existent directories to avoid warnings
63
+ reload_excludes = [d for d in reload_excludes if Path(d).exists()]
64
+
65
+ # Reload launches a supervisor plus a worker and retains file-watcher state,
66
+ # so keep it opt-in for local development. ws_max_size tracks the configured
67
+ # chat-attachment total so base64 uploads fit in one WS frame.
68
+ dev_reload = os.environ.get("DEEPTUTOR_DEV_RELOAD", "").strip().lower() in {
69
+ "1",
70
+ "true",
71
+ "yes",
72
+ "on",
73
+ }
74
+ backend_workers = max(1, int(load_system_settings().get("backend_workers") or 1))
75
+ if dev_reload and backend_workers > 1:
76
+ raise SystemExit(
77
+ "Development reload and backend_workers > 1 are mutually exclusive. "
78
+ "Set backend_workers=1 or disable DEEPTUTOR_DEV_RELOAD."
79
+ )
80
+ uvicorn.run(
81
+ "deeptutor.api.main:app",
82
+ host="0.0.0.0",
83
+ port=backend_port,
84
+ reload=dev_reload,
85
+ workers=backend_workers,
86
+ reload_excludes=reload_excludes if dev_reload else None,
87
+ log_level="info",
88
+ access_log=False,
89
+ # XFF is client-controlled in this deployment. Keep request.client tied
90
+ # to the actual backend peer for the selective access log.
91
+ proxy_headers=False,
92
+ ws_max_size=get_ws_max_size(),
93
+ timeout_keep_alive=HTTP_KEEP_ALIVE_TIMEOUT,
94
+ timeout_graceful_shutdown=1,
95
+ )
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
@@ -0,0 +1,91 @@
1
+ """Opt-in request screening with fastapi-guard.
2
+
3
+ Disabled by default. When ``system.json`` -> ``security_guard.enabled`` is false
4
+ (or ``DEEPTUTOR_GUARD_ENABLED`` is unset), nothing here imports ``guard`` and the
5
+ middleware stack is unchanged. See ``docs-for-user/SECURITY_GUARD.md``.
6
+
7
+ The guard runs inside the application, in front of the routers. It screens HTTP
8
+ requests by client address (rate limit, IP allow/deny lists, ban list) and scans
9
+ the URL path, query string and headers for attack patterns. It does not read
10
+ request bodies (prompts, documents and chat are free text), does not touch
11
+ WebSocket handshakes, and does not use Redis, CORS or security headers.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any
18
+
19
+ from fastapi import FastAPI
20
+
21
+ from deeptutor.services.config import load_system_settings
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ INSTALL_HINT = (
26
+ "security_guard is enabled but the 'fastapi-guard' package is not installed. "
27
+ "Install it with `pip install 'deeptutor[guard]'` (Docker: set "
28
+ "DEEPTUTOR_EXTRAS=guard), or disable it with security_guard.enabled=false in "
29
+ "system.json / DEEPTUTOR_GUARD_ENABLED=false."
30
+ )
31
+
32
+
33
+ def load_guard_settings() -> dict[str, Any]:
34
+ """Effective ``security_guard`` settings (file plus deployment overrides)."""
35
+ return dict(load_system_settings()["security_guard"])
36
+
37
+
38
+ def build_security_config(settings: dict[str, Any]) -> Any:
39
+ """Translate DeepTutor settings into a ``guard.SecurityConfig``."""
40
+ try:
41
+ from guard import SecurityConfig
42
+ except ImportError as exc:
43
+ raise RuntimeError(INSTALL_HINT) from exc
44
+ from guard_core.handlers.suspatterns_handler import ALL_DETECTION_CATEGORIES
45
+
46
+ return SecurityConfig(
47
+ rate_limit=settings["rate_limit"],
48
+ rate_limit_window=settings["rate_limit_window"],
49
+ trusted_proxies=tuple(settings["trusted_proxies"]),
50
+ exempt_ips=tuple(settings["exempt_ips"]),
51
+ whitelist=tuple(settings["whitelist"]) or None,
52
+ blacklist=tuple(settings["blacklist"]),
53
+ # Process-local state: each uvicorn worker keeps its own counters.
54
+ enable_redis=False,
55
+ # DeepTutor sets CORS itself (CORSMiddleware) and owns its response headers.
56
+ enable_cors=False,
57
+ security_headers={"enabled": False},
58
+ # Prompts, documents and chat messages are free text, not attack input.
59
+ detection_scan_body=False,
60
+ # Browsers send the page URL as Referer; guard-core 4.4.0 flags localhost and
61
+ # LAN addresses in it as ssrf (400), so that category stays off.
62
+ enabled_detection_categories=ALL_DETECTION_CATEGORIES - {"ssrf"},
63
+ enable_agent=False,
64
+ )
65
+
66
+
67
+ def install_security_guard(app: FastAPI, settings: dict[str, Any] | None = None) -> bool:
68
+ """Add the guard middleware to ``app`` when enabled; return whether it was added.
69
+
70
+ Call this before ``CORSMiddleware`` is added: Starlette puts the last-added
71
+ middleware outermost, so CORS stays outside the guard and 429/403 responses
72
+ still carry CORS headers.
73
+ """
74
+ settings = load_guard_settings() if settings is None else settings
75
+ if not settings["enabled"]:
76
+ return False
77
+
78
+ config = build_security_config(settings)
79
+ from guard import PureASGISecurityMiddleware
80
+
81
+ app.add_middleware(PureASGISecurityMiddleware, config=config)
82
+ logger.info(
83
+ "Security guard enabled: rate_limit=%s/%ss trusted_proxies=%s exempt_ips=%s whitelist=%s blacklist=%s",
84
+ settings["rate_limit"],
85
+ settings["rate_limit_window"],
86
+ settings["trusted_proxies"],
87
+ settings["exempt_ips"],
88
+ len(settings["whitelist"]),
89
+ len(settings["blacklist"]),
90
+ )
91
+ return True
@@ -0,0 +1,27 @@
1
+ """Shared HTTP header builders.
2
+
3
+ Small, boring, and easy to get wrong in ways that only show up for non-English
4
+ users — so they live in one place rather than once per router.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import quote
10
+
11
+
12
+ def content_disposition(filename: str, *, disposition: str = "inline") -> str:
13
+ """Build a Content-Disposition header that survives non-ASCII filenames.
14
+
15
+ HTTP/1.1 headers are latin-1, so dropping a Chinese / accented filename
16
+ straight into ``filename="..."`` blows up with UnicodeEncodeError. RFC
17
+ 6266 / RFC 5987 cover this: emit ``filename*=UTF-8''<percent-encoded>``
18
+ plus an ASCII fallback on ``filename=`` for legacy clients.
19
+ """
20
+ ascii_fallback = filename.encode("ascii", errors="replace").decode("ascii")
21
+ # Quotes / backslashes break the simple-quoted-string form; collapse them.
22
+ ascii_fallback = ascii_fallback.replace('"', "_").replace("\\", "_")
23
+ encoded = quote(filename, safe="")
24
+ return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}"
25
+
26
+
27
+ __all__ = ["content_disposition"]
@@ -0,0 +1,73 @@
1
+ """
2
+ Progress Broadcaster - Manages WebSocket broadcasting of knowledge base progress
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Optional
8
+
9
+ from fastapi import WebSocket
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ProgressBroadcaster:
15
+ """Manages WebSocket broadcasting of knowledge base progress"""
16
+
17
+ _instance: Optional["ProgressBroadcaster"] = None
18
+ _connections: dict[str, set[WebSocket]] = {} # kb_name -> Set[WebSocket]
19
+ _lock = asyncio.Lock()
20
+
21
+ @classmethod
22
+ def get_instance(cls) -> "ProgressBroadcaster":
23
+ """Get singleton instance"""
24
+ if cls._instance is None:
25
+ cls._instance = cls()
26
+ return cls._instance
27
+
28
+ async def connect(self, kb_name: str, websocket: WebSocket):
29
+ """Connect WebSocket to specified knowledge base"""
30
+ async with self._lock:
31
+ if kb_name not in self._connections:
32
+ self._connections[kb_name] = set()
33
+ self._connections[kb_name].add(websocket)
34
+ logger.debug(
35
+ f"Connected WebSocket for KB '{kb_name}' (total: {len(self._connections[kb_name])})"
36
+ )
37
+
38
+ async def disconnect(self, kb_name: str, websocket: WebSocket):
39
+ """Disconnect WebSocket connection"""
40
+ async with self._lock:
41
+ if kb_name in self._connections:
42
+ self._connections[kb_name].discard(websocket)
43
+ if not self._connections[kb_name]:
44
+ del self._connections[kb_name]
45
+ logger.debug(f"Disconnected WebSocket for KB '{kb_name}'")
46
+
47
+ async def broadcast(self, kb_name: str, progress: dict):
48
+ """Broadcast progress update to all WebSocket connections for specified knowledge base"""
49
+ async with self._lock:
50
+ if kb_name not in self._connections:
51
+ return
52
+
53
+ # Create list of connections to remove (closed connections)
54
+ to_remove = []
55
+
56
+ for websocket in self._connections[kb_name]:
57
+ try:
58
+ await websocket.send_json({"type": "progress", "data": progress})
59
+ except Exception as e:
60
+ # Connection closed or error, mark for removal
61
+ logger.debug(f"Error sending to WebSocket for KB '{kb_name}': {e}")
62
+ to_remove.append(websocket)
63
+
64
+ # Remove closed connections
65
+ for ws in to_remove:
66
+ self._connections[kb_name].discard(ws)
67
+
68
+ if not self._connections[kb_name]:
69
+ del self._connections[kb_name]
70
+
71
+ def get_connection_count(self, kb_name: str) -> int:
72
+ """Get connection count for specified knowledge base"""
73
+ return len(self._connections.get(kb_name, set()))