deeptutor-server 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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}
|