diff --git a/sentence_api/README.md b/sentence_api/README.md index 3cdeb76..252ed6b 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -62,12 +62,22 @@ POST /api/v1/admin/videos/{sha256}/process PUT /api/v1/admin/videos/{sha256}/sentences/{index} PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary +PUT /api/v1/admin/users/password DELETE /api/v1/admin/videos/{sha256} ``` Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment requests use `X-Client-Key` when `CLIENT_API_KEY` is configured. +Administrators can set a local password for an SSO-linked account with: + +```bash +curl -X PUT https://your-server/api/v1/admin/users/password \ + -H "X-Admin-Key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"username":"bobcoc","password":"new-password"}' +``` + `POST /api/v1/audio/sentence-boundaries` accepts an audio file from an authenticated client, generates boundaries in memory/temp storage only, and returns a sentence document. It does not register or archive the uploaded diff --git a/sentence_api/main.py b/sentence_api/main.py index 6dee872..a97d67d 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -44,6 +44,7 @@ from .models import ( SentenceBoundaryDocument, SentenceExplanationRequest, SentenceExplanationResponse, + PasswordResetRequest, SentenceTextUpdate, UserDubShareSummary, SsoExchangeRequest, @@ -441,6 +442,19 @@ def create_app( raise HTTPException(status_code=404, detail="User was not found.") return Response(status_code=204) + @application.put( + "/api/v1/admin/users/password", + status_code=204, + dependencies=[Depends(require_admin)], + ) + def reset_user_password( + payload: PasswordResetRequest, + ) -> Response: + updated = video_repository.set_user_password(payload.username, payload.password) + if not updated: + raise HTTPException(status_code=404, detail="User was not found.") + return Response(status_code=204) + @application.get("/api/v1/videos", response_model=VideoListResponse) def list_videos() -> VideoListResponse: return VideoListResponse( diff --git a/sentence_api/models.py b/sentence_api/models.py index f388ed5..331b4b9 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -92,6 +92,13 @@ class SentenceTextUpdate(BaseModel): language: Optional[str] = Field(default=None, max_length=32) +class PasswordResetRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: str = Field(min_length=3, max_length=50) + password: str = Field(min_length=8, max_length=128) + + class SentenceExplanationRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/sentence_api/repository.py b/sentence_api/repository.py index a49b656..8bcda5f 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -984,6 +984,22 @@ class VideoRepository: return None return self.get_user_by_id(row["id"]) + def set_user_password(self, username: str, password: str) -> bool: + """Set a local password for either a normal or SSO-linked account.""" + username = username.strip() + if not username: + return False + with self._connect() as connection: + cursor = connection.execute( + """ + UPDATE users + SET password_hash = ? + WHERE username = ? COLLATE NOCASE + """, + (hash_password(password), username), + ) + return cursor.rowcount > 0 + def create_session(self, user_id: str) -> str: token = new_token() now = datetime.now(timezone.utc) @@ -1205,4 +1221,3 @@ def _is_cjk_char(char: str) -> bool: or 0xF900 <= code <= 0xFAFF or 0x20000 <= code <= 0x2CEAF ) - diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index a2532f2..55e30c3 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -15,7 +15,7 @@ from sentence_api.store import BoundaryStore VIDEO_HASH = "a" * 64 -def make_client(tmp_path): +def make_client(tmp_path, admin_api_key=""): index_path = tmp_path / "boundaries.json" index_path.write_text( json.dumps({ @@ -36,6 +36,7 @@ def make_client(tmp_path): Settings.from_env(), data_dir=tmp_path / "data", legacy_boundaries_file=index_path, + admin_api_key=admin_api_key, ) settings.ensure_directories() client = TestClient( @@ -116,6 +117,27 @@ def test_adhoc_audio_boundaries_require_login(tmp_path): assert response.status_code == 401 +def test_admin_can_reset_sso_user_password(tmp_path): + client = make_client(tmp_path, admin_api_key="test-admin-key") + repository = client.app.state.video_repository + created = repository.create_sso_user( + username="bobcoc", + nickname="Bob", + sso_provider="d1kt", + ) + assert created is not None + assert repository.authenticate_user("bobcoc", "anything-123") is None + + response = client.put( + "/api/v1/admin/users/password", + json={"username": "bobcoc", "password": "new-password-123"}, + headers={"X-Admin-Key": "test-admin-key"}, + ) + + assert response.status_code == 204 + assert repository.authenticate_user("bobcoc", "new-password-123") is not None + + def test_sentence_explanation_requires_login(tmp_path): index_path = tmp_path / "boundaries.json" index_path.write_text("{}", encoding="utf-8")