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,1937 @@
1
+ """Auth router — login, logout, status, registration, profile, and user-management endpoints."""
2
+
3
+ from contextvars import Token as _CtxToken
4
+ import csv
5
+ from datetime import datetime, timedelta, timezone
6
+ import io
7
+ import ipaddress
8
+ import logging
9
+ import os
10
+ import re
11
+ import secrets
12
+ import time
13
+
14
+ from fastapi import (
15
+ APIRouter,
16
+ Cookie,
17
+ Depends,
18
+ File,
19
+ Header,
20
+ HTTPException,
21
+ Request,
22
+ Response,
23
+ UploadFile,
24
+ WebSocket,
25
+ status,
26
+ )
27
+ from fastapi.responses import FileResponse, HTMLResponse
28
+ from pydantic import BaseModel, Field, ValidationError, field_validator
29
+
30
+ from deeptutor.services.config import load_auth_settings
31
+
32
+ # SameSite=None lets the cookie work when the browser accesses the frontend via
33
+ # 127.0.0.1 and the backend via localhost (different origins on the same machine).
34
+ # Browsers require Secure=True for SameSite=None, but that needs HTTPS — so in
35
+ # local dev we fall back to SameSite=Lax and tell users to use localhost:// URLs.
36
+ _SECURE = bool(load_auth_settings()["cookie_secure"])
37
+ _SAMESITE = "none" if _SECURE else "lax"
38
+
39
+ from deeptutor.multi_user.audit import log_admin_action, log_usage
40
+ from deeptutor.multi_user.context import (
41
+ reset_current_user,
42
+ set_current_user,
43
+ user_from_token_payload,
44
+ )
45
+ from deeptutor.multi_user.device_credentials import (
46
+ heartbeat_device_credential,
47
+ issue_device_credential,
48
+ list_device_credentials,
49
+ revoke_device_credential,
50
+ )
51
+ from deeptutor.multi_user.identity import get_user_by_id
52
+ from deeptutor.multi_user.learning_access import learning_policy_for_user
53
+ from deeptutor.multi_user.models import AccountPreset
54
+ from deeptutor.multi_user.paths import local_admin_user
55
+ from deeptutor.multi_user.session_handoff import (
56
+ CODE_LIFETIME_SECONDS,
57
+ TICKET_LIFETIME_SECONDS,
58
+ HandoffError,
59
+ HandoffRateLimited,
60
+ HandoffRejected,
61
+ canonical_host,
62
+ decrypt_ticket_payload,
63
+ encrypt_ticket_payload,
64
+ get_session_handoff_store,
65
+ hash_secret,
66
+ is_loopback_host,
67
+ public_origin,
68
+ )
69
+ from deeptutor.services.auth import (
70
+ AUTH_ENABLED,
71
+ POCKETBASE_ENABLED,
72
+ TOKEN_EXPIRE_HOURS,
73
+ TokenPayload,
74
+ add_user,
75
+ authenticate,
76
+ authenticate_device,
77
+ authenticate_pb,
78
+ create_token,
79
+ decode_token,
80
+ delete_user,
81
+ get_user_info,
82
+ is_first_user,
83
+ list_users,
84
+ register_pb,
85
+ set_avatar,
86
+ set_learner_profile,
87
+ set_role,
88
+ )
89
+ from deeptutor.services.auth import (
90
+ get_learner_profile as load_learner_profile,
91
+ )
92
+ from deeptutor.services.codex_auth.contracts import CodexAuthError
93
+ from deeptutor.services.codex_auth.service import deliver_codex_oauth_callback
94
+
95
+ logger = logging.getLogger(__name__)
96
+
97
+ router = APIRouter()
98
+
99
+ _COOKIE_NAME = "dt_token"
100
+ AUTH_COOKIE_NAME = _COOKIE_NAME
101
+ _COOKIE_MAX_AGE = TOKEN_EXPIRE_HOURS * 3600
102
+ _USER_IMPORT_MAX_BYTES = 2 * 1024 * 1024
103
+ _USER_IMPORT_MAX_ROWS = 500
104
+ _USER_BATCH_MAX_ROWS = 500
105
+ _FRONTEND_HOST_HEADER = "x-deeptutor-frontend-host"
106
+ _AUTH_RUNTIME_SETTINGS = load_auth_settings()
107
+ PRIVATE_LOGIN_HOSTS = frozenset(
108
+ str(host).lower().rstrip(".") for host in _AUTH_RUNTIME_SETTINGS.get("private_login_hosts", [])
109
+ )
110
+
111
+
112
+ # Only a frontend proxy on an explicitly trusted peer may assert the browser
113
+ # host. Never derive this identity from Host or X-Forwarded-* on a direct API call.
114
+ # The frontend's incoming Host is meaningful only when its public ingress
115
+ # rejects forged private hosts and direct access to its raw listener.
116
+ def _trusted_proxy_ips() -> frozenset[str]:
117
+ raw = os.environ.get("AUTH_TRUSTED_FRONTEND_PROXY_IPS", "127.0.0.1,::1")
118
+ trusted: set[str] = set()
119
+ for item in raw.split(","):
120
+ try:
121
+ trusted.add(str(ipaddress.ip_address(item.strip())))
122
+ except ValueError:
123
+ continue
124
+ return frozenset(trusted)
125
+
126
+
127
+ TRUSTED_FRONTEND_PROXY_IPS = _trusted_proxy_ips()
128
+
129
+
130
+ def _cookie_attrs() -> dict:
131
+ """Attribute set shared by ``login``'s ``set_cookie`` and ``logout``'s
132
+ ``delete_cookie``.
133
+
134
+ The deletion ``Set-Cookie`` must carry the same attributes as the one
135
+ that created the cookie — ``delete_cookie`` defaults ``secure=False``,
136
+ which browsers reject when paired with ``SameSite=None``, silently
137
+ keeping the old cookie. See #623. Reads the module globals at call time
138
+ so tests can monkeypatch ``_SECURE``/``_SAMESITE``.
139
+ """
140
+ return {
141
+ "key": _COOKIE_NAME,
142
+ "httponly": True,
143
+ "samesite": _SAMESITE,
144
+ "secure": _SECURE,
145
+ }
146
+
147
+
148
+ def _request_frontend_host(request: Request) -> str:
149
+ peer = request.client.host if request.client else ""
150
+ try:
151
+ peer_ip = str(ipaddress.ip_address(peer))
152
+ except ValueError:
153
+ return ""
154
+ if peer_ip not in TRUSTED_FRONTEND_PROXY_IPS:
155
+ return ""
156
+ raw = request.headers.get(_FRONTEND_HOST_HEADER, "")
157
+ try:
158
+ return canonical_host(raw)
159
+ except HandoffError:
160
+ return ""
161
+
162
+
163
+ def _require_private_frontend(request: Request) -> None:
164
+ """Restrict credential endpoints only when private hosts are configured."""
165
+
166
+ if not PRIVATE_LOGIN_HOSTS:
167
+ return
168
+ host = _request_frontend_host(request)
169
+ if is_loopback_host(host) or host in PRIVATE_LOGIN_HOSTS:
170
+ return
171
+ logger.warning(
172
+ "Credential endpoint refused for non-private frontend host policy",
173
+ )
174
+ raise HTTPException(
175
+ status_code=status.HTTP_403_FORBIDDEN,
176
+ detail="Sign-in is only available from a private DeepTutor origin",
177
+ )
178
+
179
+
180
+ def _no_store(response: Response) -> None:
181
+ response.headers["Cache-Control"] = "no-store"
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Schemas
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ class LoginRequest(BaseModel):
190
+ """Payload for the POST /login endpoint."""
191
+
192
+ username: str
193
+ password: str
194
+
195
+
196
+ class DeviceLoginRequest(BaseModel):
197
+ """Payload for the built-in device-credential login endpoint."""
198
+
199
+ pairing_code: str = Field(min_length=8, max_length=128)
200
+ pin: str = Field(min_length=6, max_length=6)
201
+
202
+
203
+ class DeviceCredentialCreateRequest(BaseModel):
204
+ """Admin payload for issuing a local ordinary-user device credential."""
205
+
206
+ user_id: str = Field(min_length=1, max_length=64)
207
+ device_name: str = Field(min_length=1, max_length=80)
208
+ expires_in_days: int = Field(ge=1, le=365)
209
+ daily_limit_minutes: int = Field(ge=5, le=1440)
210
+
211
+
212
+ class RegisterRequest(BaseModel):
213
+ """Payload for the POST /register endpoint."""
214
+
215
+ username: str
216
+ password: str
217
+
218
+ @field_validator("username")
219
+ @classmethod
220
+ def username_valid(cls, v: str) -> str:
221
+ import re
222
+
223
+ v = v.strip()
224
+ if not v:
225
+ raise ValueError("Email cannot be empty")
226
+ # Accept standard email addresses (used by PocketBase mode) or plain
227
+ # usernames (used by the built-in SQLite/JSON auth mode).
228
+ email_re = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
229
+ plain_re = re.compile(r"^[A-Za-z0-9_\-.]{3,64}$")
230
+ if not email_re.match(v) and not plain_re.match(v):
231
+ raise ValueError("Enter a valid email address")
232
+ return v
233
+
234
+ @field_validator("password")
235
+ @classmethod
236
+ def password_valid(cls, v: str) -> str:
237
+ if len(v) < 8:
238
+ raise ValueError("Password must be at least 8 characters")
239
+ return v
240
+
241
+
242
+ class SessionHandoffCreateRequest(BaseModel):
243
+ """Private request that starts a one-time public handoff."""
244
+
245
+ public_origin: str = Field(min_length=8, max_length=2048)
246
+
247
+
248
+ class SessionHandoffExchangeRequest(BaseModel):
249
+ """Public request that trades a pairing code for a JWE ticket."""
250
+
251
+ code: str = Field(min_length=16, max_length=256)
252
+
253
+
254
+ class SessionHandoffCompleteRequest(BaseModel):
255
+ """Public request that trades a JWE ticket for the normal cookie."""
256
+
257
+ ticket: str = Field(min_length=32, max_length=8192)
258
+
259
+
260
+ class SetRoleRequest(BaseModel):
261
+ """Payload for the PUT /users/{username}/role endpoint."""
262
+
263
+ role: str
264
+
265
+ @field_validator("role")
266
+ @classmethod
267
+ def role_valid(cls, v: str) -> str:
268
+ # Validate against the same whitelist the identity store enforces so
269
+ # the API layer and the storage layer share a single source of truth;
270
+ # a hardcoded subset here would 422 roles the store itself accepts.
271
+ from deeptutor.multi_user.models import VALID_ROLES
272
+
273
+ if v not in VALID_ROLES:
274
+ raise ValueError(f"Role must be one of {sorted(VALID_ROLES)}")
275
+ return v
276
+
277
+
278
+ class AdminCreateUserRequest(RegisterRequest):
279
+ """Admin user-creation payload.
280
+
281
+ A preset configures an ordinary account; it never becomes a third role.
282
+ """
283
+
284
+ preset: AccountPreset = "standard"
285
+
286
+
287
+ class AdminBatchDeleteRequest(BaseModel):
288
+ """Usernames for an admin-initiated batch deletion."""
289
+
290
+ usernames: list[str] = Field(min_length=1, max_length=_USER_BATCH_MAX_ROWS)
291
+
292
+ @field_validator("usernames")
293
+ @classmethod
294
+ def usernames_valid(cls, value: list[str]) -> list[str]:
295
+ usernames: list[str] = []
296
+ for raw_username in value:
297
+ username = raw_username.strip()
298
+ if not username:
299
+ raise ValueError("Usernames cannot be empty")
300
+ if username not in usernames:
301
+ usernames.append(username)
302
+ if not usernames:
303
+ raise ValueError("At least one username is required")
304
+ return usernames
305
+
306
+
307
+ class AuthStatusResponse(BaseModel):
308
+ """Response body for the GET /status endpoint."""
309
+
310
+ enabled: bool
311
+ authenticated: bool
312
+ user_id: str | None = None
313
+ username: str | None = None
314
+ role: str | None = None
315
+ is_admin: bool = False
316
+ avatar: str = ""
317
+ preset: AccountPreset | None = None
318
+ learning_policy: dict | None = None
319
+
320
+
321
+ class UserInfo(BaseModel):
322
+ """Single user record returned by the GET /users and /profile endpoints."""
323
+
324
+ id: str = ""
325
+ username: str
326
+ role: str
327
+ created_at: str
328
+ disabled: bool = False
329
+ avatar: str = ""
330
+ preset: AccountPreset = "standard"
331
+
332
+
333
+ class LearnerProfileRequest(BaseModel):
334
+ age: int | None = Field(default=None, ge=3, le=120)
335
+ grade_level: str | None = Field(default=None, max_length=80)
336
+ curriculum: str | None = Field(default=None, max_length=80)
337
+ language: str | None = Field(default=None, max_length=80)
338
+ reading_level: str | None = Field(default=None, max_length=80)
339
+ explanation_style: str | None = Field(default=None, max_length=80)
340
+
341
+
342
+ # Markers settable through PUT /profile. Image markers ("img:<version>") are
343
+ # managed exclusively by the upload endpoint so users cannot point their
344
+ # avatar at a file that was never validated.
345
+ _ICON_MARKER_RE = re.compile(r"^icon:[a-z0-9-]{1,32}:[a-z0-9-]{1,32}$")
346
+
347
+ # User ids are generated as "u_<uuid hex>" (plus the "local-admin" /
348
+ # "env-admin" sentinels); reject anything else before it reaches the
349
+ # filesystem layer.
350
+ _USER_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
351
+
352
+
353
+ class UpdateProfileRequest(BaseModel):
354
+ """Payload for the PUT /profile endpoint."""
355
+
356
+ avatar: str
357
+
358
+ @field_validator("avatar")
359
+ @classmethod
360
+ def avatar_valid(cls, v: str) -> str:
361
+ v = v.strip()
362
+ if v and not _ICON_MARKER_RE.match(v):
363
+ raise ValueError("Avatar must be empty or 'icon:<name>:<color>'")
364
+ return v
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Shared helper — extract token from cookie or Bearer header
369
+ # ---------------------------------------------------------------------------
370
+
371
+
372
+ def _bearer_token_from_header(authorization: str | None) -> str | None:
373
+ """Parse ``Authorization: Bearer <token>`` without using ``HTTPBearer``.
374
+
375
+ ``HTTPBearer`` is a class-based dependency whose ``__call__`` is annotated
376
+ ``request: Request``. FastAPI doesn't inject a Request into WebSocket
377
+ dependency resolution, which makes ``HTTPBearer`` raise ``TypeError`` the
378
+ moment a router with this dep mounts a WS endpoint. Doing the parse by
379
+ hand keeps ``require_auth`` HTTP/WS-symmetric.
380
+ """
381
+ if not authorization:
382
+ return None
383
+ parts = authorization.split(None, 1)
384
+ if len(parts) == 2 and parts[0].lower() == "bearer":
385
+ token = parts[1].strip()
386
+ return token or None
387
+ return None
388
+
389
+
390
+ def _extract_token(authorization: str | None, dt_token: str | None) -> str | None:
391
+ return _bearer_token_from_header(authorization) or dt_token
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Dependencies — reusable auth guards for other routers
396
+ # ---------------------------------------------------------------------------
397
+
398
+
399
+ def _install_current_user(payload: TokenPayload | None) -> _CtxToken:
400
+ """Install the request-local current-user ContextVar from an auth result.
401
+
402
+ Single point of truth for ``payload → CurrentUser`` so HTTP and WebSocket
403
+ entry points produce identical user objects. ``payload is None`` means
404
+ "no JWT was required" (AUTH_ENABLED=false) and resolves to the local
405
+ admin user; a non-None payload resolves through ``user_from_token_payload``.
406
+
407
+ Returns the ContextVar reset token. HTTP callers ignore it (the request
408
+ ends with the task, so the var is GC'd with the task context). WebSocket
409
+ callers keep it and call ``reset_current_user`` in their ``finally`` block,
410
+ because a WS connection outlives the dependency-resolution task.
411
+
412
+ ⚠ Invariant: every authenticated entry point MUST call this before the
413
+ handler runs. Skipping it leaves ``get_current_path_service()`` falling
414
+ back to the admin workspace — the silent-routing root cause of #481.
415
+ """
416
+ user = local_admin_user() if payload is None else user_from_token_payload(payload)
417
+ return set_current_user(user)
418
+
419
+
420
+ async def require_auth(
421
+ authorization: str | None = Header(default=None, alias="Authorization"),
422
+ dt_token: str | None = Cookie(default=None, alias=_COOKIE_NAME),
423
+ request: Request = None,
424
+ ) -> TokenPayload | None:
425
+ """
426
+ FastAPI dependency that enforces authentication when AUTH_ENABLED=true.
427
+
428
+ Accepts the JWT from either:
429
+ - Authorization: Bearer <token> header
430
+ - dt_token cookie
431
+
432
+ ``Header`` and ``Cookie`` are kept here in place of ``HTTPBearer`` so the
433
+ function stays usable from WebSocket call sites that don't go through
434
+ FastAPI's standard HTTP request lifecycle.
435
+
436
+ Returns the authenticated TokenPayload, or None if auth is disabled.
437
+ Raises HTTP 401 if auth is enabled but the token is missing or invalid.
438
+
439
+ Declared ``async def`` so the ``set_current_user`` call runs in the same
440
+ asyncio context as the endpoint. A sync dependency is dispatched via
441
+ ``anyio.to_thread.run_sync``, which executes the function in a worker
442
+ thread under a *copy* of the request context; any ``ContextVar.set``
443
+ inside that thread is discarded when the thread returns, leaving the
444
+ endpoint to read the unset default. That regression was the root cause
445
+ of #481.
446
+ """
447
+ if not AUTH_ENABLED:
448
+ _install_current_user(None)
449
+ _install_request_workspace(request)
450
+ return None
451
+
452
+ token = _extract_token(authorization, dt_token)
453
+ if not token:
454
+ raise HTTPException(
455
+ status_code=status.HTTP_401_UNAUTHORIZED,
456
+ detail="Not authenticated",
457
+ headers={"WWW-Authenticate": "Bearer"},
458
+ )
459
+
460
+ payload = decode_token(token)
461
+ if not payload:
462
+ raise HTTPException(
463
+ status_code=status.HTTP_401_UNAUTHORIZED,
464
+ detail="Invalid or expired token",
465
+ headers={"WWW-Authenticate": "Bearer"},
466
+ )
467
+
468
+ _install_current_user(payload)
469
+ _install_request_workspace(request)
470
+ return payload
471
+
472
+
473
+ def _install_request_workspace(request) -> None:
474
+ from deeptutor.services.workspace.context import install_workspace_scope
475
+ from deeptutor.services.workspace.models import WorkspaceError
476
+
477
+ headers = getattr(request, "headers", {})
478
+ params = getattr(request, "query_params", {})
479
+ header = headers.get("x-deeptutor-workspace")
480
+ query = params.get("dt_workspace")
481
+ if header is not None and query is not None and header != query:
482
+ raise HTTPException(status_code=400, detail="Conflicting workspace scopes.")
483
+ try:
484
+ path = getattr(getattr(request, "url", None), "path", "")
485
+ from deeptutor.services.workspace.context import resolve_workspace_scope
486
+ from deeptutor.services.workspace.knowledge import library_request, library_viewer
487
+
488
+ library_request.set(
489
+ path.startswith(("/api/knowledge-bases", "/ws/knowledge-bases"))
490
+ and params.get("resource_library") == "true"
491
+ )
492
+ library_viewer.set(
493
+ resolve_workspace_scope(header if header is not None else query).workspace_id
494
+ if library_request.get() and (header is not None or query is not None)
495
+ else None
496
+ )
497
+ from deeptutor.services.skill.runtime import library_workspace
498
+
499
+ skill_workspace = (
500
+ params.get("skill_workspace", "") if path.startswith("/api/skills") else ""
501
+ )
502
+ library_workspace.set(skill_workspace)
503
+ catalog_management = library_request.get() or path.startswith(
504
+ ("/api/skills", "/api/space/mcp")
505
+ )
506
+ management = path.startswith(
507
+ ("/api/settings", "/api/auth", "/api/multi-user", "/api/task-board", "/api/calendar")
508
+ )
509
+ selected = install_workspace_scope(
510
+ None if management or catalog_management else header if header is not None else query
511
+ )
512
+ if skill_workspace:
513
+ from deeptutor.services.workspace import get_content_workspace_service
514
+
515
+ service = get_content_workspace_service()
516
+ service.validate_chat_binding(
517
+ skill_workspace,
518
+ existing=getattr(request, "method", "GET") in {"GET", "HEAD", "OPTIONS"},
519
+ )
520
+ if (
521
+ not management
522
+ and selected.archived
523
+ and getattr(request, "method", "GET") not in {"GET", "HEAD", "OPTIONS"}
524
+ ):
525
+ raise WorkspaceError("Restore this workspace before changing its data.")
526
+ # Management/migration requests acquire their own exclusive lease.
527
+ task_board_request = path == "/api/task-board" or path.startswith("/api/task-board/")
528
+ if getattr(request, "method", None) is not None and (
529
+ not management
530
+ or (task_board_request and path != "/api/task-board/events")
531
+ or (path.startswith("/api/calendar/") and path != "/api/calendar/stream")
532
+ ):
533
+ from deeptutor.services.workspace.activity import acquire_activity
534
+
535
+ state = getattr(request, "state", None)
536
+ if state is not None and getattr(state, "workspace_activity", None) is None:
537
+ state.workspace_activity = acquire_activity()
538
+ except WorkspaceError as exc:
539
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
540
+
541
+
542
+ class _WsAuthFailed:
543
+ """Sentinel: ws_require_auth failed and closed the WebSocket."""
544
+
545
+
546
+ ws_auth_failed: _WsAuthFailed = _WsAuthFailed()
547
+
548
+
549
+ async def ws_require_auth(ws: WebSocket) -> _CtxToken | _WsAuthFailed:
550
+ """Authenticate a WebSocket connection and set the user ContextVar.
551
+
552
+ Must be called **before** ``ws.accept()`` so the server can reject
553
+ unauthenticated upgrades cleanly.
554
+
555
+ Returns a ContextVar reset token on success, or ``ws_auth_failed``
556
+ on failure (the WebSocket is already closed — the caller should
557
+ ``return`` immediately).
558
+
559
+ Usage::
560
+
561
+ user_token = await ws_require_auth(ws)
562
+ if user_token is ws_auth_failed:
563
+ return
564
+ await ws.accept()
565
+ try:
566
+ ...
567
+ finally:
568
+ reset_current_user(user_token)
569
+ """
570
+ payload = None
571
+ if AUTH_ENABLED:
572
+ token = ws.query_params.get("token") or ws.cookies.get(_COOKIE_NAME)
573
+ payload = decode_token(token) if token else None
574
+ if not payload:
575
+ await ws.close(code=4001)
576
+ return ws_auth_failed
577
+ user_token = _install_current_user(payload)
578
+ try:
579
+ _install_request_workspace(ws)
580
+ except HTTPException:
581
+ reset_current_user(user_token)
582
+ await ws.close(code=4004)
583
+ return ws_auth_failed
584
+ return user_token
585
+
586
+
587
+ async def require_admin(
588
+ payload: TokenPayload | None = Depends(require_auth),
589
+ ) -> TokenPayload:
590
+ """
591
+ FastAPI dependency that requires the caller to be an admin.
592
+
593
+ Raises HTTP 403 if the authenticated user is not an admin.
594
+ When AUTH_ENABLED=false, all requests are treated as admin.
595
+
596
+ ``async def`` mirrors ``require_auth`` so the dependency chain stays on
597
+ the event loop and the user ContextVar set by ``require_auth`` is visible
598
+ to the endpoint.
599
+ """
600
+ if not AUTH_ENABLED:
601
+ return _local_admin_token_payload()
602
+
603
+ if payload is None or payload.role != "admin":
604
+ raise HTTPException(
605
+ status_code=status.HTTP_403_FORBIDDEN,
606
+ detail="Admin access required",
607
+ )
608
+ return payload
609
+
610
+
611
+ _LEARNER_KB_READ_ROUTES = frozenset(
612
+ {
613
+ "/api/knowledge-bases",
614
+ "/api/knowledge-bases/list",
615
+ "/api/knowledge-bases/{kb_name}",
616
+ "/api/knowledge-bases/{kb_name}/files",
617
+ "/api/knowledge-bases/{kb_name}/files/{filename:path}",
618
+ "/api/knowledge-bases/{kb_name}/file-preview-text/{filename:path}",
619
+ "/api/knowledge-bases/{kb_name}/visual-assets/{asset_id}",
620
+ "/api/knowledge-bases/{kb_name}/indexing-run",
621
+ "/api/knowledge-bases/{kb_name}/indexing-readiness",
622
+ "/api/knowledge-bases/{kb_name}/progress",
623
+ }
624
+ )
625
+
626
+
627
+ _PERSONAL_PAGE_ROUTES = frozenset(
628
+ {
629
+ ("GET", "/api/settings/page-customization"),
630
+ ("PUT", "/api/settings/page-customization/chat"),
631
+ ("PUT", "/api/settings/page-customization/learning"),
632
+ }
633
+ )
634
+
635
+
636
+ _LEARNER_SETTINGS_WRITE_ROUTES = frozenset(
637
+ {
638
+ # UI preferences resolve through the per-user path service into the
639
+ # caller's OWN workspace (interface.json) — saving theme/language is
640
+ # safe for learning accounts.
641
+ ("PUT", "/api/settings/ui"),
642
+ *_PERSONAL_PAGE_ROUTES,
643
+ # Per-user settings draft document. Applying a draft to the runtime
644
+ # catalog stays admin-gated in the endpoint itself.
645
+ ("PUT", "/api/settings/draft"),
646
+ ("DELETE", "/api/settings/draft"),
647
+ # Workspace self-management: the caller's own registrations inside
648
+ # their own scope directory. Data migration/export/root-migration
649
+ # routes under the same prefix stay denied — they move data across
650
+ # scopes and carry no internal admin gate.
651
+ ("PUT", "/api/settings/workspace"),
652
+ ("POST", "/api/settings/workspace/validate"),
653
+ ("POST", "/api/settings/workspace/registrations"),
654
+ ("PATCH", "/api/settings/workspace/registrations/{workspace_id}"),
655
+ }
656
+ )
657
+
658
+
659
+ def _learning_surface_for_path(
660
+ path: str, method: str = "GET", *, route_path: str | None = None
661
+ ) -> str:
662
+ normalized = "/" + str(path or "").lstrip("/")
663
+ for root, surface in (
664
+ ("/api/reading", "reading"),
665
+ ("/api/courses", "reading"),
666
+ ("/api/dashboard/learning-library/materials", "reading"),
667
+ ("/api/dashboard/learning-library/reading", "reading"),
668
+ ("/api/books", "books"),
669
+ ("/api/dashboard/learning-library/books", "books"),
670
+ ("/api/dashboard/learning-library/mastery", "chat"),
671
+ ("/api/chat", "chat"),
672
+ ("/api/question", "chat"),
673
+ ("/api/question-notebook", "chat"),
674
+ ("/api/sessions", "chat"),
675
+ # Task cards are private to the learner's current content workspace.
676
+ ("/api/task-board", "chat"),
677
+ ("/api/calendar", "chat"),
678
+ # Mastery Path progress/topics are the learner's own per-user data;
679
+ # the router already scopes every record to the current account, so
680
+ # all methods (including progress PATCH/POST) belong to "chat".
681
+ ("/api/mastery-paths", "chat"),
682
+ ):
683
+ if normalized == root or normalized.startswith(f"{root}/"):
684
+ return surface
685
+ # Match the resolved route template, not just the URL prefix: this keeps
686
+ # admin diagnostics and engine configuration GETs out of the learner shell.
687
+ if (
688
+ method.upper() == "GET"
689
+ and (normalized == "/api/knowledge-bases" or normalized.startswith("/api/knowledge-bases/"))
690
+ and route_path in _LEARNER_KB_READ_ROUTES
691
+ ):
692
+ return "reading"
693
+ # Learner-safe settings/workspace writes, matched on the resolved route
694
+ # template like the KB reads above: the same URL prefixes also host
695
+ # admin-grade operations (catalog apply, data migration), so a plain
696
+ # prefix match would open too much.
697
+ if route_path and (method.upper(), route_path) in _LEARNER_SETTINGS_WRITE_ROUTES:
698
+ return "chat"
699
+ # Choosing the model for a chat turn is part of chat. The handler is
700
+ # already grant-filtered. Match this exact path only: a prefix of
701
+ # /api/settings would also open catalog writes on the same router (#1222).
702
+ if method.upper() == "GET" and normalized == "/api/settings/llm-options":
703
+ return "chat"
704
+ return ""
705
+
706
+
707
+ def _resolved_route_path(request: Request) -> str | None:
708
+ """Keep include-time prefixes across FastAPI's flat and lazy routers."""
709
+ # Lazy router inclusion retains the original route in scope["route"].
710
+ # Its path omits include_router prefixes; the effective context owns the
711
+ # full matched template. Direct routes and older releases use the route.
712
+ fastapi_scope = request.scope.get("fastapi")
713
+ if isinstance(fastapi_scope, dict):
714
+ context = fastapi_scope.get("effective_route_context")
715
+ path = getattr(context, "path", None)
716
+ if isinstance(path, str):
717
+ return path
718
+ path = getattr(request.scope.get("route"), "path", None)
719
+ return path if isinstance(path, str) else None
720
+
721
+
722
+ async def require_learning_surface(
723
+ request: Request,
724
+ _: TokenPayload | None = Depends(require_auth),
725
+ ) -> None:
726
+ """Second-stage default-deny guard for configured learning accounts."""
727
+ from deeptutor.multi_user.learning_access import (
728
+ assert_learning_surface,
729
+ learning_surface_allowed,
730
+ )
731
+
732
+ route_path = _resolved_route_path(request)
733
+ # Personal presentation does not grant a learning surface. Any authenticated
734
+ # learner may customize these two pages, including reading-only accounts.
735
+ if (request.method, route_path) in _PERSONAL_PAGE_ROUTES:
736
+ return
737
+ # The landing page combines independent learning surfaces (#1987). Admit
738
+ # any granted surface; the handler filters its readers with the same policy.
739
+ if (
740
+ request.method == "GET"
741
+ and request.url.path == "/api/dashboard/learning-index"
742
+ and route_path == "/api/dashboard/learning-index"
743
+ and any(learning_surface_allowed(surface) for surface in ("books", "chat", "reading"))
744
+ ):
745
+ return
746
+
747
+ # This aggregate filters each source by the current policy itself. Exact
748
+ # GET only: it must never open the rest of the dashboard to learners.
749
+ if request.method == "GET" and request.url.path == "/api/dashboard/learning-index":
750
+ return
751
+
752
+ try:
753
+ assert_learning_surface(
754
+ _learning_surface_for_path(
755
+ request.url.path,
756
+ request.method,
757
+ route_path=route_path,
758
+ )
759
+ )
760
+ except PermissionError as exc:
761
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
762
+
763
+
764
+ def _local_admin_token_payload() -> TokenPayload:
765
+ """Synthetic admin payload used when AUTH_ENABLED=false.
766
+
767
+ Mirrors the local admin identity (LOCAL_ADMIN_USERNAME / LOCAL_ADMIN_ID)
768
+ so audit logs and self-reference checks behave the same as in multi-user
769
+ mode. Values are kept aligned with ``local_admin_user()`` in
770
+ ``deeptutor/multi_user/paths.py``.
771
+ """
772
+ from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME
773
+
774
+ return TokenPayload(
775
+ username=LOCAL_ADMIN_USERNAME,
776
+ role="admin",
777
+ user_id=LOCAL_ADMIN_ID,
778
+ )
779
+
780
+
781
+ # ---------------------------------------------------------------------------
782
+ # Public endpoints (no auth required)
783
+ # ---------------------------------------------------------------------------
784
+
785
+
786
+ @router.get("/openai-codex/callback")
787
+ async def receive_codex_oauth_callback(
788
+ request: Request,
789
+ code: str | None = None,
790
+ state: str | None = None,
791
+ error: str | None = None,
792
+ ) -> HTMLResponse:
793
+ headers = {"Cache-Control": "no-store"}
794
+ try:
795
+ callback_state = state if len(request.query_params.getlist("state")) == 1 else None
796
+ await deliver_codex_oauth_callback(code, callback_state, error)
797
+ except CodexAuthError as exc:
798
+ return HTMLResponse(
799
+ (
800
+ "<!doctype html><title>DeepTutor Codex</title>"
801
+ "<p>Authentication could not be received. Return to DeepTutor and try again.</p>"
802
+ ),
803
+ status_code=exc.http_status,
804
+ headers=headers,
805
+ )
806
+ return HTMLResponse(
807
+ (
808
+ "<!doctype html><title>DeepTutor Codex</title>"
809
+ "<p>Authentication received. You can return to DeepTutor.</p>"
810
+ ),
811
+ headers=headers,
812
+ )
813
+
814
+
815
+ @router.get("/status", response_model=AuthStatusResponse)
816
+ async def auth_status(
817
+ authorization: str | None = Header(default=None, alias="Authorization"),
818
+ dt_token: str | None = Cookie(default=None, alias=_COOKIE_NAME),
819
+ ) -> AuthStatusResponse:
820
+ """Return whether auth is enabled and whether the current request is authenticated."""
821
+ if not AUTH_ENABLED:
822
+ return AuthStatusResponse(
823
+ enabled=False,
824
+ authenticated=True,
825
+ user_id="local-admin",
826
+ username="local",
827
+ role="admin",
828
+ is_admin=True,
829
+ preset="standard",
830
+ )
831
+
832
+ token = _extract_token(authorization, dt_token)
833
+ payload = decode_token(token) if token else None
834
+ avatar = ""
835
+ preset: AccountPreset | None = None
836
+ learning_policy = None
837
+ if payload is not None:
838
+ info = get_user_info(payload.username)
839
+ if info:
840
+ avatar = str(info.get("avatar") or "")
841
+ raw_preset = str(info.get("preset") or "standard")
842
+ if raw_preset == "learner":
843
+ preset = "learner"
844
+ elif raw_preset == "custom":
845
+ preset = "custom"
846
+ else:
847
+ preset = "standard"
848
+ learning_policy = learning_policy_for_user(
849
+ payload.user_id,
850
+ is_admin=payload.role == "admin",
851
+ )
852
+ # Guardians may attach a policy to a standard or custom account. That
853
+ # policy, not the stored label, is what the runtime must enforce and
854
+ # what clients need to render the scoped experience.
855
+ if learning_policy is not None and preset != "learner":
856
+ preset = "learner"
857
+ return AuthStatusResponse(
858
+ enabled=True,
859
+ authenticated=payload is not None,
860
+ user_id=payload.user_id if payload else None,
861
+ username=payload.username if payload else None,
862
+ role=payload.role if payload else None,
863
+ is_admin=payload.role == "admin" if payload else False,
864
+ avatar=avatar,
865
+ preset=preset,
866
+ learning_policy=learning_policy,
867
+ )
868
+
869
+
870
+ @router.post("/login")
871
+ async def login(body: LoginRequest, request: Request, response: Response) -> dict:
872
+ """Validate credentials and set a JWT cookie."""
873
+ if not AUTH_ENABLED:
874
+ return {"ok": True, "message": "Auth is disabled — no login required."}
875
+
876
+ _require_private_frontend(request)
877
+ _no_store(response)
878
+
879
+ if POCKETBASE_ENABLED:
880
+ # PocketBase mode: email = username field for backwards-compat with the
881
+ # existing LoginRequest schema; users can pass their email as "username".
882
+ pb_result = authenticate_pb(body.username, body.password)
883
+ if not pb_result:
884
+ raise HTTPException(
885
+ status_code=status.HTTP_401_UNAUTHORIZED,
886
+ detail="Incorrect email or password",
887
+ )
888
+ payload, pb_token = pb_result
889
+ response.set_cookie(value=pb_token, max_age=_COOKIE_MAX_AGE, **_cookie_attrs())
890
+ logger.info(f"User '{payload.username}' logged in via PocketBase (role={payload.role!r})")
891
+ return {
892
+ "ok": True,
893
+ "user_id": payload.user_id,
894
+ "username": payload.username,
895
+ "role": payload.role,
896
+ "is_admin": payload.role == "admin",
897
+ }
898
+
899
+ # Standard JWT + bcrypt mode
900
+ result = authenticate(body.username, body.password)
901
+ if not result:
902
+ raise HTTPException(
903
+ status_code=status.HTTP_401_UNAUTHORIZED,
904
+ detail="Incorrect username or password",
905
+ )
906
+
907
+ token = create_token(result.username, result.role, result.user_id)
908
+ response.set_cookie(value=token, max_age=_COOKIE_MAX_AGE, **_cookie_attrs())
909
+
910
+ logger.info(f"User '{result.username}' logged in (role={result.role!r})")
911
+ return {
912
+ "ok": True,
913
+ "user_id": result.user_id,
914
+ "username": result.username,
915
+ "role": result.role,
916
+ "is_admin": result.role == "admin",
917
+ }
918
+
919
+
920
+ def _require_builtin_device_auth() -> None:
921
+ if not AUTH_ENABLED:
922
+ raise HTTPException(
923
+ status_code=status.HTTP_400_BAD_REQUEST,
924
+ detail="Device credentials require built-in authentication.",
925
+ )
926
+ if POCKETBASE_ENABLED:
927
+ raise HTTPException(
928
+ status_code=status.HTTP_400_BAD_REQUEST,
929
+ detail="Device credentials are not supported in PocketBase mode.",
930
+ )
931
+
932
+
933
+ @router.post("/device-login")
934
+ async def device_login(body: DeviceLoginRequest, response: Response) -> dict:
935
+ """Exchange a device pairing code and PIN for the account's normal cookie."""
936
+
937
+ _require_builtin_device_auth()
938
+ payload = authenticate_device(body.pairing_code, body.pin)
939
+ if payload is None:
940
+ raise HTTPException(
941
+ status_code=status.HTTP_401_UNAUTHORIZED,
942
+ detail="Incorrect device credentials",
943
+ )
944
+
945
+ token = create_token(
946
+ payload.username,
947
+ payload.role,
948
+ payload.user_id,
949
+ device_credential_id=payload.device_credential_id,
950
+ device_session_nonce=payload.device_session_nonce,
951
+ )
952
+ response.set_cookie(value=token, max_age=_COOKIE_MAX_AGE, **_cookie_attrs())
953
+ logger.info(f"User '{payload.username}' logged in with a device credential")
954
+ return {
955
+ "ok": True,
956
+ "user_id": payload.user_id,
957
+ "username": payload.username,
958
+ "role": payload.role,
959
+ "is_admin": payload.role == "admin",
960
+ "device_credential_id": payload.device_credential_id,
961
+ }
962
+
963
+
964
+ @router.post("/session-handoff")
965
+ async def create_session_handoff(
966
+ body: SessionHandoffCreateRequest,
967
+ request: Request,
968
+ response: Response,
969
+ authorization: str | None = Header(default=None, alias="Authorization"),
970
+ dt_token: str | None = Cookie(default=None, alias=_COOKIE_NAME),
971
+ payload: TokenPayload | None = Depends(require_auth),
972
+ ) -> dict:
973
+ """Create a host-bound, one-time pairing code from the private origin."""
974
+
975
+ _no_store(response)
976
+ if not AUTH_ENABLED or payload is None or not PRIVATE_LOGIN_HOSTS:
977
+ raise HTTPException(
978
+ status_code=status.HTTP_400_BAD_REQUEST,
979
+ detail="Session handoff requires authentication and configured private login hosts.",
980
+ )
981
+ _require_private_frontend(request)
982
+
983
+ try:
984
+ target_origin = public_origin(body.public_origin)
985
+ target_host = canonical_host(target_origin.removeprefix("https://"))
986
+ now = int(time.time())
987
+ ticket_claims: dict[str, object] = {
988
+ "nonce": secrets.token_urlsafe(24),
989
+ "host": target_host,
990
+ # Rotated at code exchange; this initial upper bound cannot expire
991
+ # before a valid pairing code is presented.
992
+ "exp": now + CODE_LIFETIME_SECONDS + TICKET_LIFETIME_SECONDS,
993
+ }
994
+ if POCKETBASE_ENABLED:
995
+ ticket_claims.update(
996
+ {"mode": "pocketbase", "token": _extract_token(authorization, dt_token)}
997
+ )
998
+ else:
999
+ ticket_claims.update(
1000
+ {
1001
+ "mode": "builtin",
1002
+ "username": payload.username,
1003
+ "role": payload.role,
1004
+ "user_id": payload.user_id,
1005
+ "device_credential_id": payload.device_credential_id,
1006
+ "device_session_nonce": payload.device_session_nonce,
1007
+ }
1008
+ )
1009
+ ticket = encrypt_ticket_payload(ticket_claims)
1010
+ record = get_session_handoff_store().create(
1011
+ encrypted_ticket=ticket,
1012
+ ticket_hash=hash_secret(ticket),
1013
+ public_host=target_host,
1014
+ rate_key=payload.user_id or payload.username,
1015
+ )
1016
+ except HandoffRateLimited:
1017
+ raise HTTPException(
1018
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
1019
+ detail="Too many handoff requests. Try again later.",
1020
+ )
1021
+ except HandoffError:
1022
+ raise HTTPException(
1023
+ status_code=status.HTTP_400_BAD_REQUEST,
1024
+ detail="Enter a valid HTTPS public origin",
1025
+ )
1026
+
1027
+ logger.info(
1028
+ "Created session handoff for user=%s target_host=%s",
1029
+ payload.username,
1030
+ target_host,
1031
+ )
1032
+ return {
1033
+ "ok": True,
1034
+ "code": record.code,
1035
+ "handoff_url": f"{target_origin}/handoff?code={record.code}",
1036
+ "expires_at": record.expires_at,
1037
+ "expires_in": CODE_LIFETIME_SECONDS,
1038
+ }
1039
+
1040
+
1041
+ @router.post("/session-handoff/exchange")
1042
+ async def exchange_session_handoff(
1043
+ body: SessionHandoffExchangeRequest,
1044
+ request: Request,
1045
+ response: Response,
1046
+ ) -> dict:
1047
+ """Consume a pairing code and return a short-lived ticket in the response body."""
1048
+
1049
+ _no_store(response)
1050
+ host = _request_frontend_host(request)
1051
+ if not host:
1052
+ raise HTTPException(
1053
+ status_code=status.HTTP_400_BAD_REQUEST,
1054
+ detail="Pairing request has no valid frontend host",
1055
+ )
1056
+ try:
1057
+ ticket = get_session_handoff_store().exchange(
1058
+ code=body.code,
1059
+ public_host=host,
1060
+ )
1061
+ except HandoffRateLimited:
1062
+ raise HTTPException(
1063
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
1064
+ detail="Too many handoff requests. Try again later.",
1065
+ )
1066
+ except HandoffError:
1067
+ raise HTTPException(
1068
+ status_code=status.HTTP_400_BAD_REQUEST,
1069
+ detail="Pairing code is invalid, expired, or already used",
1070
+ )
1071
+ logger.info("Exchanged session handoff code for host=%s", host)
1072
+ return {"ok": True, "ticket": ticket}
1073
+
1074
+
1075
+ @router.post("/session-handoff/complete")
1076
+ async def complete_session_handoff(
1077
+ body: SessionHandoffCompleteRequest,
1078
+ request: Request,
1079
+ response: Response,
1080
+ ) -> dict:
1081
+ """Consume a ticket once and issue the normal HttpOnly session cookie."""
1082
+
1083
+ _no_store(response)
1084
+ host = _request_frontend_host(request)
1085
+ if not host:
1086
+ raise HTTPException(
1087
+ status_code=status.HTTP_400_BAD_REQUEST,
1088
+ detail="Handoff request has no valid frontend host",
1089
+ )
1090
+ try:
1091
+ ticket = get_session_handoff_store().consume_ticket(
1092
+ ticket=body.ticket,
1093
+ public_host=host,
1094
+ )
1095
+ claims = decrypt_ticket_payload(ticket)
1096
+ if str(claims.get("host")) != host:
1097
+ raise HandoffRejected("Invalid handoff ticket")
1098
+ try:
1099
+ expires_at = int(claims.get("exp"))
1100
+ except (TypeError, ValueError):
1101
+ raise HandoffRejected("Invalid handoff ticket") from None
1102
+ if expires_at <= int(time.time()):
1103
+ raise HandoffRejected("Invalid handoff ticket")
1104
+
1105
+ mode = str(claims.get("mode"))
1106
+ if mode == "pocketbase":
1107
+ token = str(claims.get("token") or "")
1108
+ if not token or decode_token(token) is None:
1109
+ raise HandoffRejected("Invalid handoff ticket")
1110
+ elif mode == "builtin":
1111
+ token = create_token(
1112
+ str(claims.get("username") or ""),
1113
+ str(claims.get("role") or "user"),
1114
+ str(claims.get("user_id") or ""),
1115
+ device_credential_id=str(claims.get("device_credential_id") or ""),
1116
+ device_session_nonce=str(claims.get("device_session_nonce") or ""),
1117
+ )
1118
+ else:
1119
+ raise HandoffRejected("Invalid handoff ticket")
1120
+ except HandoffRateLimited:
1121
+ raise HTTPException(
1122
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
1123
+ detail="Too many handoff requests. Try again later.",
1124
+ )
1125
+ except HandoffError:
1126
+ raise HTTPException(
1127
+ status_code=status.HTTP_400_BAD_REQUEST,
1128
+ detail="Handoff ticket is invalid, expired, or already used",
1129
+ )
1130
+
1131
+ response.set_cookie(value=token, max_age=_COOKIE_MAX_AGE, **_cookie_attrs())
1132
+ logger.info("Completed session handoff for host=%s", host)
1133
+ return {"ok": True}
1134
+
1135
+
1136
+ @router.post("/device/heartbeat")
1137
+ async def device_heartbeat(
1138
+ response: Response,
1139
+ payload: TokenPayload | None = Depends(require_auth),
1140
+ ) -> dict:
1141
+ """Refresh a device lease and account bounded daily usage."""
1142
+
1143
+ if not AUTH_ENABLED:
1144
+ raise HTTPException(
1145
+ status_code=status.HTTP_400_BAD_REQUEST,
1146
+ detail="Device credentials require built-in authentication.",
1147
+ )
1148
+ if payload is None or not payload.device_credential_id or not payload.device_session_nonce:
1149
+ raise HTTPException(
1150
+ status_code=status.HTTP_400_BAD_REQUEST,
1151
+ detail="This session does not use a device credential.",
1152
+ )
1153
+ try:
1154
+ device = heartbeat_device_credential(
1155
+ payload.device_credential_id,
1156
+ user_id=payload.user_id,
1157
+ session_nonce=payload.device_session_nonce,
1158
+ )
1159
+ except ValueError:
1160
+ response.delete_cookie(**_cookie_attrs())
1161
+ raise HTTPException(
1162
+ status_code=status.HTTP_401_UNAUTHORIZED,
1163
+ detail="Device session is no longer active",
1164
+ ) from None
1165
+ return {"ok": not device.pop("limit_reached"), **device}
1166
+
1167
+
1168
+ @router.post("/logout")
1169
+ async def logout(response: Response) -> dict:
1170
+ """Clear the JWT cookie.
1171
+
1172
+ Deletion attributes mirror ``login`` structurally via ``_cookie_attrs()``
1173
+ (see the rationale there and #623).
1174
+ """
1175
+ response.delete_cookie(**_cookie_attrs())
1176
+ return {"ok": True}
1177
+
1178
+
1179
+ @router.post("/register", status_code=status.HTTP_201_CREATED)
1180
+ async def register(body: RegisterRequest, request: Request, response: Response) -> dict:
1181
+ """
1182
+ Bootstrap-only registration.
1183
+
1184
+ Public endpoint that creates the *first* admin account when the user store
1185
+ is empty. Once an admin exists, this endpoint is closed; further accounts
1186
+ must be created by an admin via ``POST /api/auth/users``.
1187
+
1188
+ Only available when AUTH_ENABLED=true.
1189
+ """
1190
+ if not AUTH_ENABLED:
1191
+ raise HTTPException(
1192
+ status_code=status.HTTP_400_BAD_REQUEST,
1193
+ detail="Auth is disabled — registration is not available.",
1194
+ )
1195
+
1196
+ _require_private_frontend(request)
1197
+ _no_store(response)
1198
+
1199
+ if POCKETBASE_ENABLED:
1200
+ # PocketBase deployments are documented as single-user. Keep registration
1201
+ # closed and require admins to provision users in the PocketBase admin UI.
1202
+ if not is_first_user():
1203
+ raise HTTPException(
1204
+ status_code=status.HTTP_403_FORBIDDEN,
1205
+ detail="Self-registration is closed. Ask an administrator to create your account.",
1206
+ )
1207
+ result = register_pb(username=body.username, email=body.username, password=body.password)
1208
+ if not result:
1209
+ raise HTTPException(
1210
+ status_code=status.HTTP_409_CONFLICT,
1211
+ detail="Registration failed — username or email may already be taken.",
1212
+ )
1213
+ logger.info(f"First user registered via PocketBase: '{body.username}'")
1214
+ return {
1215
+ "ok": True,
1216
+ "user_id": result.get("id", ""),
1217
+ "username": body.username,
1218
+ "role": "user",
1219
+ "is_first_user": True,
1220
+ "is_admin": False,
1221
+ }
1222
+
1223
+ # Standard mode — only allowed before the first admin exists.
1224
+ if not is_first_user():
1225
+ raise HTTPException(
1226
+ status_code=status.HTTP_403_FORBIDDEN,
1227
+ detail="Self-registration is closed. Ask an administrator to create your account.",
1228
+ )
1229
+
1230
+ existing = {u["username"] for u in list_users()}
1231
+ if body.username in existing:
1232
+ raise HTTPException(
1233
+ status_code=status.HTTP_409_CONFLICT,
1234
+ detail="Username already taken",
1235
+ )
1236
+
1237
+ add_user(body.username, body.password)
1238
+ user_id = ""
1239
+ role = "user"
1240
+ for item in list_users():
1241
+ if item.get("username") == body.username:
1242
+ user_id = str(item.get("id") or "")
1243
+ role = str(item.get("role") or "user")
1244
+ break
1245
+ logger.info(f"First user (admin) registered: '{body.username}'")
1246
+ return {
1247
+ "ok": True,
1248
+ "user_id": user_id,
1249
+ "username": body.username,
1250
+ "role": role,
1251
+ "is_first_user": True,
1252
+ "is_admin": role == "admin",
1253
+ }
1254
+
1255
+
1256
+ @router.get("/is_first_user")
1257
+ async def check_is_first_user() -> dict:
1258
+ """Return whether the user store is empty (used by the register UI)."""
1259
+ return {"is_first_user": is_first_user() if AUTH_ENABLED else False}
1260
+
1261
+
1262
+ # ---------------------------------------------------------------------------
1263
+ # Profile endpoints (any authenticated user, self-service)
1264
+ # ---------------------------------------------------------------------------
1265
+
1266
+ _AVATAR_MAX_BYTES = 1 * 1024 * 1024
1267
+ _AVATAR_MEDIA_TYPES = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
1268
+
1269
+
1270
+ def _sniff_image(data: bytes) -> str | None:
1271
+ """Detect a supported raster image format from its magic bytes.
1272
+
1273
+ The uploaded filename and Content-Type are attacker-controlled, so the
1274
+ stored extension (and the media type served back) is derived from the
1275
+ bytes alone. SVG is deliberately unsupported — serving user-supplied SVG
1276
+ is a stored-XSS vector.
1277
+ """
1278
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
1279
+ return "png"
1280
+ if data[:3] == b"\xff\xd8\xff":
1281
+ return "jpg"
1282
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
1283
+ return "webp"
1284
+ return None
1285
+
1286
+
1287
+ def _require_profile_identity(payload: TokenPayload | None) -> TokenPayload:
1288
+ """Shared guard for the self-service profile endpoints."""
1289
+ if not AUTH_ENABLED or payload is None:
1290
+ raise HTTPException(
1291
+ status_code=status.HTTP_400_BAD_REQUEST,
1292
+ detail="Auth is disabled — profiles are not available.",
1293
+ )
1294
+ return payload
1295
+
1296
+
1297
+ @router.get("/profile", response_model=UserInfo)
1298
+ async def get_profile(
1299
+ payload: TokenPayload | None = Depends(require_auth),
1300
+ ) -> UserInfo:
1301
+ """Return the current user's own account info."""
1302
+ current = _require_profile_identity(payload)
1303
+ info = get_user_info(current.username)
1304
+ if info is None:
1305
+ # PocketBase-backed identities have no local record; fall back to the
1306
+ # token claims so the profile page still renders.
1307
+ return UserInfo(
1308
+ id=current.user_id,
1309
+ username=current.username,
1310
+ role=current.role,
1311
+ created_at="",
1312
+ )
1313
+ return UserInfo(**info)
1314
+
1315
+
1316
+ @router.put("/profile")
1317
+ async def update_profile(
1318
+ body: UpdateProfileRequest,
1319
+ payload: TokenPayload | None = Depends(require_auth),
1320
+ ) -> dict:
1321
+ """Update the current user's own avatar marker (icon choice or reset).
1322
+
1323
+ Only the validated ``icon:<name>:<color>`` form (or empty string) is
1324
+ accepted here; ``img:`` markers are owned by the upload endpoint.
1325
+ """
1326
+ current = _require_profile_identity(payload)
1327
+ if not set_avatar(current.username, body.avatar):
1328
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1329
+ # The marker no longer references an uploaded image, so drop the file.
1330
+ from deeptutor.multi_user.identity import delete_avatar_file
1331
+
1332
+ if current.user_id and _USER_ID_RE.match(current.user_id):
1333
+ delete_avatar_file(current.user_id)
1334
+ return {"ok": True, "avatar": body.avatar}
1335
+
1336
+
1337
+ @router.put("/profile/avatar")
1338
+ async def upload_avatar(
1339
+ file: UploadFile = File(...),
1340
+ payload: TokenPayload | None = Depends(require_auth),
1341
+ ) -> dict:
1342
+ """Upload an avatar image for the current user.
1343
+
1344
+ The client is expected to crop/resize before uploading; the server only
1345
+ enforces a size cap and validates the format by magic bytes. Not available
1346
+ in PocketBase mode (those identities have no local user record).
1347
+ """
1348
+ current = _require_profile_identity(payload)
1349
+ if POCKETBASE_ENABLED:
1350
+ raise HTTPException(
1351
+ status_code=status.HTTP_400_BAD_REQUEST,
1352
+ detail="Avatar upload is not available in PocketBase mode.",
1353
+ )
1354
+ if not current.user_id or not _USER_ID_RE.match(current.user_id):
1355
+ raise HTTPException(
1356
+ status_code=status.HTTP_400_BAD_REQUEST,
1357
+ detail="Cannot store an avatar for this account.",
1358
+ )
1359
+ info = get_user_info(current.username)
1360
+ if info is None:
1361
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1362
+
1363
+ data = await file.read(_AVATAR_MAX_BYTES + 1)
1364
+ if len(data) > _AVATAR_MAX_BYTES:
1365
+ raise HTTPException(
1366
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
1367
+ detail="Avatar image is too large (max 1 MB).",
1368
+ )
1369
+ ext = _sniff_image(data)
1370
+ if ext is None:
1371
+ raise HTTPException(
1372
+ status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
1373
+ detail="Avatar must be a PNG, JPEG or WebP image.",
1374
+ )
1375
+
1376
+ from deeptutor.multi_user.identity import save_avatar_file
1377
+
1378
+ # Bump the version embedded in the marker so clients cache-bust the URL.
1379
+ previous = str(info.get("avatar") or "")
1380
+ version = 1
1381
+ if previous.startswith("img:"):
1382
+ try:
1383
+ version = int(previous.split(":", 1)[1]) + 1
1384
+ except ValueError:
1385
+ version = 1
1386
+ marker = f"img:{version}"
1387
+
1388
+ save_avatar_file(current.user_id, data, ext)
1389
+ if not set_avatar(current.username, marker):
1390
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1391
+ logger.info(f"User '{current.username}' uploaded a new avatar ({ext}, {len(data)} bytes)")
1392
+ return {"ok": True, "avatar": marker}
1393
+
1394
+
1395
+ @router.delete("/profile/avatar")
1396
+ async def remove_avatar(
1397
+ payload: TokenPayload | None = Depends(require_auth),
1398
+ ) -> dict:
1399
+ """Remove the current user's uploaded avatar image and reset the marker."""
1400
+ current = _require_profile_identity(payload)
1401
+ from deeptutor.multi_user.identity import delete_avatar_file
1402
+
1403
+ if current.user_id and _USER_ID_RE.match(current.user_id):
1404
+ delete_avatar_file(current.user_id)
1405
+ set_avatar(current.username, "")
1406
+ return {"ok": True, "avatar": ""}
1407
+
1408
+
1409
+ @router.get("/avatar/{user_id}")
1410
+ async def get_avatar_image(
1411
+ user_id: str,
1412
+ _: TokenPayload | None = Depends(require_auth),
1413
+ ) -> FileResponse:
1414
+ """Serve a stored avatar image. Any authenticated user may view avatars
1415
+ (they appear in the admin table and next to the viewer's own profile)."""
1416
+ if not _USER_ID_RE.match(user_id):
1417
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
1418
+
1419
+ from deeptutor.multi_user.identity import get_avatar_file
1420
+
1421
+ target = get_avatar_file(user_id)
1422
+ if target is None:
1423
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
1424
+
1425
+ media_type = _AVATAR_MEDIA_TYPES.get(target.suffix.lstrip("."), "application/octet-stream")
1426
+ headers = {
1427
+ # Private user content; the marker version in the URL handles busting.
1428
+ "Cache-Control": "private, max-age=86400",
1429
+ "X-Content-Type-Options": "nosniff",
1430
+ "Content-Disposition": "inline",
1431
+ }
1432
+ return FileResponse(path=str(target), media_type=media_type, headers=headers)
1433
+
1434
+
1435
+ # ---------------------------------------------------------------------------
1436
+ # Admin-only endpoints
1437
+ # ---------------------------------------------------------------------------
1438
+
1439
+
1440
+ @router.get("/devices")
1441
+ async def list_devices(
1442
+ user_id: str | None = None,
1443
+ include_revoked: bool = False,
1444
+ _: TokenPayload = Depends(require_admin),
1445
+ ) -> dict:
1446
+ """List local device credential metadata without credential secrets."""
1447
+
1448
+ _require_builtin_device_auth()
1449
+ credentials = list_device_credentials(user_id=user_id, include_revoked=include_revoked)
1450
+ users = {str(user.get("id") or ""): str(user.get("username") or "") for user in list_users()}
1451
+ return {
1452
+ "devices": [
1453
+ {**device, "username": users.get(device["user_id"], "")} for device in credentials
1454
+ ]
1455
+ }
1456
+
1457
+
1458
+ @router.post("/devices", status_code=status.HTTP_201_CREATED)
1459
+ async def issue_device(
1460
+ body: DeviceCredentialCreateRequest,
1461
+ current: TokenPayload = Depends(require_admin),
1462
+ ) -> dict:
1463
+ """Issue a revocable device credential for an ordinary local account."""
1464
+
1465
+ _require_builtin_device_auth()
1466
+ if get_user_by_id(body.user_id) is None:
1467
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1468
+ try:
1469
+ device, pairing_code, pin = issue_device_credential(
1470
+ user_id=body.user_id,
1471
+ device_name=body.device_name,
1472
+ expires_at=datetime.now(timezone.utc) + timedelta(days=body.expires_in_days),
1473
+ daily_limit_minutes=body.daily_limit_minutes,
1474
+ )
1475
+ except ValueError as exc:
1476
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
1477
+ log_admin_action(
1478
+ "device_credential_issue",
1479
+ target_user_id=body.user_id,
1480
+ summary={
1481
+ "device_credential_id": device["id"],
1482
+ "device_name": device["device_name"],
1483
+ "expires_at": device["expires_at"],
1484
+ "daily_limit_minutes": device["daily_limit_minutes"],
1485
+ },
1486
+ )
1487
+ logger.info(
1488
+ f"Admin '{current.username if current else 'local'}' issued device "
1489
+ f"credential {device['id']} for user id '{body.user_id}'"
1490
+ )
1491
+ return {
1492
+ "device": device,
1493
+ "pairing_code": pairing_code,
1494
+ "pin": pin,
1495
+ }
1496
+
1497
+
1498
+ @router.delete("/devices/{device_credential_id}")
1499
+ async def revoke_device(
1500
+ device_credential_id: str,
1501
+ current: TokenPayload = Depends(require_admin),
1502
+ ) -> dict:
1503
+ _require_builtin_device_auth()
1504
+ device = revoke_device_credential(
1505
+ device_credential_id,
1506
+ revoked_by=str(current.user_id if current else ""),
1507
+ )
1508
+ if device is None:
1509
+ raise HTTPException(
1510
+ status_code=status.HTTP_404_NOT_FOUND,
1511
+ detail="Device credential not found",
1512
+ )
1513
+ log_admin_action(
1514
+ "device_credential_revoke",
1515
+ target_user_id=device["user_id"],
1516
+ summary={"device_credential_id": device["id"]},
1517
+ )
1518
+ return {"device": device, "ok": True}
1519
+
1520
+
1521
+ @router.get("/users", response_model=list[UserInfo])
1522
+ async def get_users(_: TokenPayload = Depends(require_admin)) -> list[UserInfo]:
1523
+ """List all registered users. Requires admin role."""
1524
+ return [UserInfo(**u) for u in list_users()]
1525
+
1526
+
1527
+ def _require_local_learner(current: TokenPayload) -> tuple[str, dict]:
1528
+ """Resolve a self-service profile request to its local learner account."""
1529
+
1530
+ if current.role == "admin":
1531
+ raise HTTPException(
1532
+ status_code=status.HTTP_403_FORBIDDEN, detail="Learner profile required"
1533
+ )
1534
+ account = get_user_by_id(current.user_id)
1535
+ if account is None or account[0] != current.username:
1536
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1537
+ if str(account[1].get("preset") or "standard") != "learner":
1538
+ raise HTTPException(
1539
+ status_code=status.HTTP_403_FORBIDDEN, detail="Learner profile required"
1540
+ )
1541
+ return account
1542
+
1543
+
1544
+ @router.get("/profile/learner-profile")
1545
+ async def get_current_learner_profile(current: TokenPayload = Depends(require_auth)) -> dict:
1546
+ """Return the authenticated learner's own profile."""
1547
+ _require_local_learner(current)
1548
+ profile = load_learner_profile(current.username)
1549
+ return {"learner_profile": profile}
1550
+
1551
+
1552
+ @router.put("/profile/learner-profile")
1553
+ async def put_current_learner_profile(
1554
+ body: LearnerProfileRequest,
1555
+ current: TokenPayload = Depends(require_auth),
1556
+ ) -> dict:
1557
+ """Update only the authenticated learner's own profile."""
1558
+ _require_local_learner(current)
1559
+ from deeptutor.multi_user.learner_profile import normalize_profile
1560
+
1561
+ try:
1562
+ profile = normalize_profile(body.model_dump(exclude_none=True))
1563
+ except ValueError as exc:
1564
+ raise HTTPException(
1565
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
1566
+ ) from exc
1567
+ updated = set_learner_profile(current.username, profile)
1568
+ log_usage(
1569
+ "learner_profile",
1570
+ current.user_id,
1571
+ "self_update",
1572
+ {"fields": sorted(profile or {})},
1573
+ )
1574
+ return {"learner_profile": updated}
1575
+
1576
+
1577
+ @router.get("/users/{username}/learner-profile")
1578
+ async def get_learner_profile(username: str, _: TokenPayload = Depends(require_admin)) -> dict:
1579
+ """Return the structured profile managed for an ordinary learner."""
1580
+ from deeptutor.multi_user.identity import get_user
1581
+
1582
+ user = get_user(username)
1583
+ if (
1584
+ user is None
1585
+ or str(user.get("role") or "user") == "admin"
1586
+ or str(user.get("preset") or "standard") != "learner"
1587
+ ):
1588
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1589
+ return {"learner_profile": user.get("learner_profile")}
1590
+
1591
+
1592
+ @router.put("/users/{username}/learner-profile")
1593
+ async def put_learner_profile(
1594
+ username: str,
1595
+ body: LearnerProfileRequest,
1596
+ current: TokenPayload = Depends(require_admin),
1597
+ ) -> dict:
1598
+ from deeptutor.multi_user.identity import get_user
1599
+ from deeptutor.multi_user.learner_profile import normalize_profile
1600
+
1601
+ user = get_user(username)
1602
+ if (
1603
+ user is None
1604
+ or str(user.get("role") or "user") == "admin"
1605
+ or str(user.get("preset") or "standard") != "learner"
1606
+ ):
1607
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1608
+ try:
1609
+ profile = normalize_profile(body.model_dump(exclude_none=True))
1610
+ except ValueError as exc:
1611
+ raise HTTPException(
1612
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
1613
+ ) from exc
1614
+ updated = set_learner_profile(username, profile)
1615
+ log_admin_action(
1616
+ "learner_profile_update",
1617
+ target_user_id=str(user.get("id") or ""),
1618
+ summary={"fields": sorted(profile or {})},
1619
+ )
1620
+ logger.info("Admin '%s' updated learner profile for '%s'", current.username, username)
1621
+ return {"learner_profile": updated}
1622
+
1623
+
1624
+ def _validation_message(exc: ValidationError) -> str:
1625
+ parts = []
1626
+ for error in exc.errors():
1627
+ location = ".".join(str(item) for item in error.get("loc", ()))
1628
+ parts.append(f"{location or 'row'}: {error.get('msg', 'Invalid value')}")
1629
+ return "; ".join(parts)
1630
+
1631
+
1632
+ def _create_admin_user(
1633
+ body: AdminCreateUserRequest,
1634
+ current: TokenPayload | None,
1635
+ ) -> dict:
1636
+ """Create one ordinary account through the shared admin provision path."""
1637
+ actor = current.username if current else "local"
1638
+
1639
+ if POCKETBASE_ENABLED:
1640
+ if body.preset != "standard":
1641
+ raise HTTPException(
1642
+ status_code=status.HTTP_400_BAD_REQUEST,
1643
+ detail="Only the standard preset is available in PocketBase mode.",
1644
+ )
1645
+ result = register_pb(username=body.username, email=body.username, password=body.password)
1646
+ if not result:
1647
+ raise HTTPException(
1648
+ status_code=status.HTTP_409_CONFLICT,
1649
+ detail="Failed to create user — username may already be taken.",
1650
+ )
1651
+ logger.info("Admin '%s' created PocketBase user '%s'", actor, body.username)
1652
+ return {
1653
+ "ok": True,
1654
+ "user_id": result.get("id", ""),
1655
+ "username": body.username,
1656
+ "role": "user",
1657
+ "is_admin": False,
1658
+ "preset": "standard",
1659
+ }
1660
+
1661
+ if any(item.get("username") == body.username for item in list_users()):
1662
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
1663
+
1664
+ add_user(body.username, body.password, preset=body.preset)
1665
+ created = next(
1666
+ (item for item in list_users() if item.get("username") == body.username),
1667
+ None,
1668
+ )
1669
+ user_id = str(created.get("id") or "") if created else ""
1670
+ role = str(created.get("role") or "user") if created else "user"
1671
+ preset = str(created.get("preset") or "standard") if created else "standard"
1672
+
1673
+ if role != "user":
1674
+ delete_user(body.username)
1675
+ raise HTTPException(
1676
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1677
+ detail="Only non-admin users can be provisioned by this endpoint.",
1678
+ )
1679
+
1680
+ if preset == "learner":
1681
+ from deeptutor.multi_user.grants import learner_grant, save_grant
1682
+
1683
+ try:
1684
+ save_grant(user_id, learner_grant(user_id))
1685
+ except Exception as exc:
1686
+ rolled_back = False
1687
+ try:
1688
+ rolled_back = delete_user(body.username)
1689
+ except Exception:
1690
+ logger.exception(
1691
+ "Failed to roll back user '%s' after learner grant initialization failed",
1692
+ body.username,
1693
+ )
1694
+ if not rolled_back:
1695
+ logger.error(
1696
+ "Learner account '%s' may remain after grant initialization failed",
1697
+ body.username,
1698
+ )
1699
+ raise HTTPException(
1700
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1701
+ detail="The learner preset could not be initialized.",
1702
+ ) from exc
1703
+
1704
+ logger.info(
1705
+ "Admin '%s' created user '%s' (role=%r, preset=%r)",
1706
+ actor,
1707
+ body.username,
1708
+ role,
1709
+ preset,
1710
+ )
1711
+ return {
1712
+ "ok": True,
1713
+ "user_id": user_id,
1714
+ "username": body.username,
1715
+ "role": role,
1716
+ "is_admin": False,
1717
+ "preset": preset,
1718
+ }
1719
+
1720
+
1721
+ @router.post("/users", status_code=status.HTTP_201_CREATED)
1722
+ async def admin_create_user(
1723
+ body: AdminCreateUserRequest,
1724
+ current: TokenPayload = Depends(require_admin),
1725
+ ) -> dict:
1726
+ """Admin-only: create one ordinary user account."""
1727
+ if not AUTH_ENABLED:
1728
+ raise HTTPException(
1729
+ status_code=status.HTTP_400_BAD_REQUEST,
1730
+ detail="Auth is disabled — user creation is not available.",
1731
+ )
1732
+ return _create_admin_user(body, current)
1733
+
1734
+
1735
+ @router.post("/users/import")
1736
+ async def admin_import_users(
1737
+ file: UploadFile = File(...),
1738
+ current: TokenPayload = Depends(require_admin),
1739
+ ) -> dict:
1740
+ """Admin-only: provision ordinary users from a CSV file.
1741
+
1742
+ The response is row-oriented so one invalid record does not erase the
1743
+ context of the valid records around it. Passwords are never returned.
1744
+ """
1745
+ if not AUTH_ENABLED:
1746
+ raise HTTPException(
1747
+ status_code=status.HTTP_400_BAD_REQUEST,
1748
+ detail="Auth is disabled — user import is not available.",
1749
+ )
1750
+
1751
+ content = await file.read(_USER_IMPORT_MAX_BYTES + 1)
1752
+ await file.close()
1753
+ if len(content) > _USER_IMPORT_MAX_BYTES:
1754
+ raise HTTPException(
1755
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
1756
+ detail="User import file exceeds 2 MB.",
1757
+ )
1758
+ try:
1759
+ text = content.decode("utf-8-sig")
1760
+ except UnicodeDecodeError as exc:
1761
+ raise HTTPException(
1762
+ status_code=status.HTTP_400_BAD_REQUEST,
1763
+ detail="User import must be a UTF-8 CSV file.",
1764
+ ) from exc
1765
+
1766
+ reader = csv.DictReader(io.StringIO(text, newline=""))
1767
+ fieldnames = [str(name or "").strip().lower() for name in reader.fieldnames or []]
1768
+ expected = ["username", "password", "preset"]
1769
+ if fieldnames != expected:
1770
+ raise HTTPException(
1771
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
1772
+ detail="CSV header must be exactly: username,password,preset",
1773
+ )
1774
+ # DictReader keeps the original header spelling as dictionary keys.
1775
+ reader.fieldnames = expected
1776
+
1777
+ parsed: list[tuple[int, AdminCreateUserRequest, str]] = []
1778
+ results: list[dict] = []
1779
+ rows = list(reader)
1780
+ if not rows:
1781
+ raise HTTPException(
1782
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
1783
+ detail="User import file contains no user rows.",
1784
+ )
1785
+ if len(rows) > _USER_IMPORT_MAX_ROWS:
1786
+ raise HTTPException(
1787
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
1788
+ detail=f"User import cannot exceed {_USER_IMPORT_MAX_ROWS} rows.",
1789
+ )
1790
+ for row_number, row in enumerate(rows, start=2):
1791
+ username = str(row.get("username") or "").strip()
1792
+ if row.get(None):
1793
+ results.append(
1794
+ {
1795
+ "row": row_number,
1796
+ "username": username[:100],
1797
+ "ok": False,
1798
+ "error": "Row has more values than the CSV header.",
1799
+ }
1800
+ )
1801
+ continue
1802
+ try:
1803
+ body = AdminCreateUserRequest(
1804
+ username=username,
1805
+ password=str(row.get("password") or ""),
1806
+ preset=str(row.get("preset") or "").strip() or "standard",
1807
+ )
1808
+ except ValidationError as exc:
1809
+ results.append(
1810
+ {
1811
+ "row": row_number,
1812
+ "username": username[:100],
1813
+ "ok": False,
1814
+ "error": _validation_message(exc),
1815
+ }
1816
+ )
1817
+ continue
1818
+ parsed.append((row_number, body, username[:100]))
1819
+
1820
+ for row_number, body, username in parsed:
1821
+ try:
1822
+ created = _create_admin_user(body, current)
1823
+ except HTTPException as exc:
1824
+ detail = exc.detail if isinstance(exc.detail, str) else "Failed to create user"
1825
+ results.append({"row": row_number, "username": username, "ok": False, "error": detail})
1826
+ except Exception:
1827
+ logger.exception("Admin user import failed for row %s", row_number)
1828
+ results.append(
1829
+ {
1830
+ "row": row_number,
1831
+ "username": username,
1832
+ "ok": False,
1833
+ "error": "Failed to create user.",
1834
+ }
1835
+ )
1836
+ else:
1837
+ results.append({"row": row_number, "username": username, "ok": True, "user": created})
1838
+
1839
+ results.sort(key=lambda result: result["row"])
1840
+ created_count = sum(1 for result in results if result["ok"])
1841
+ failed_count = len(results) - created_count
1842
+ logger.info(
1843
+ "Admin '%s' imported users: %s created, %s failed",
1844
+ current.username if current else "local",
1845
+ created_count,
1846
+ failed_count,
1847
+ )
1848
+ return {
1849
+ "ok": failed_count == 0,
1850
+ "created_count": created_count,
1851
+ "failed_count": failed_count,
1852
+ "results": results,
1853
+ }
1854
+
1855
+
1856
+ def _delete_admin_user(username: str, current: TokenPayload | None) -> tuple[int, str]:
1857
+ if current and username == current.username:
1858
+ return 400, "You cannot delete your own account"
1859
+
1860
+ # Capture the id before the record disappears so the avatar file can go too.
1861
+ info = get_user_info(username)
1862
+
1863
+ removed = delete_user(username)
1864
+ if not removed:
1865
+ return 404, "User not found"
1866
+
1867
+ user_id = str(info.get("id") or "") if info else ""
1868
+ if user_id and _USER_ID_RE.match(user_id):
1869
+ from deeptutor.multi_user.identity import delete_avatar_file
1870
+
1871
+ delete_avatar_file(user_id)
1872
+
1873
+ actor = current.username if current else "local"
1874
+ logger.info("Admin '%s' deleted user '%s'", actor, username)
1875
+ return 200, ""
1876
+
1877
+
1878
+ @router.delete("/users/{username}", status_code=status.HTTP_200_OK)
1879
+ async def remove_user(
1880
+ username: str,
1881
+ current: TokenPayload = Depends(require_admin),
1882
+ ) -> dict:
1883
+ """Delete a user. Admins cannot delete their own account."""
1884
+ status_code, detail = _delete_admin_user(username, current)
1885
+ if status_code != 200:
1886
+ raise HTTPException(status_code=status_code, detail=detail)
1887
+ return {"ok": True}
1888
+
1889
+
1890
+ @router.post("/users/batch-delete")
1891
+ async def batch_remove_users(
1892
+ body: AdminBatchDeleteRequest,
1893
+ current: TokenPayload = Depends(require_admin),
1894
+ ) -> dict:
1895
+ """Delete selected users and report each outcome separately."""
1896
+ results: list[dict] = []
1897
+ for username in body.usernames:
1898
+ _status_code, detail = _delete_admin_user(username, current)
1899
+ results.append({"username": username, "ok": not detail, "error": detail or None})
1900
+
1901
+ deleted_count = sum(1 for result in results if result["ok"])
1902
+ failed_count = len(results) - deleted_count
1903
+ logger.info(
1904
+ "Admin '%s' batch-deleted users: %s deleted, %s failed",
1905
+ current.username if current else "local",
1906
+ deleted_count,
1907
+ failed_count,
1908
+ )
1909
+ return {
1910
+ "ok": failed_count == 0,
1911
+ "deleted_count": deleted_count,
1912
+ "failed_count": failed_count,
1913
+ "results": results,
1914
+ }
1915
+
1916
+
1917
+ @router.put("/users/{username}/role", status_code=status.HTTP_200_OK)
1918
+ async def update_user_role(
1919
+ username: str,
1920
+ body: SetRoleRequest,
1921
+ current: TokenPayload = Depends(require_admin),
1922
+ ) -> dict:
1923
+ """Change a user's role. Admins cannot change their own role."""
1924
+ if current and username == current.username:
1925
+ raise HTTPException(
1926
+ status_code=status.HTTP_400_BAD_REQUEST,
1927
+ detail="You cannot change your own role",
1928
+ )
1929
+
1930
+ updated = set_role(username, body.role)
1931
+ if not updated:
1932
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
1933
+
1934
+ logger.info(
1935
+ f"Admin '{current.username if current else 'local'}' set '{username}' role to {body.role!r}"
1936
+ )
1937
+ return {"ok": True, "username": username, "role": body.role}