From e2ba502697ce331c4d87fb019648fcbe4e7cca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20Garc=C3=ADa?= Date: Tue, 24 Mar 2026 17:17:52 -0500 Subject: [PATCH] feat: send email in share transcript and add email sending in room (#924) * fix: add source language for file pipeline * feat: send email in share transcript and add email sending in room * fix: hide audio and video streaming for unauthenticated users * fix: security order --- ...8f9a012_add_email_transcript_to_to_room.py | 28 ++ server/reflector/app.py | 2 + server/reflector/db/rooms.py | 4 + .../workflows/daily_multitrack_pipeline.py | 23 +- .../hatchet/workflows/file_pipeline.py | 24 +- .../hatchet/workflows/live_post_pipeline.py | 23 +- server/reflector/storage/base.py | 6 +- server/reflector/storage/storage_aws.py | 9 +- server/reflector/views/config.py | 20 ++ server/reflector/views/rooms.py | 4 + server/reflector/views/transcripts.py | 29 +++ server/reflector/views/transcripts_audio.py | 25 +- server/reflector/views/transcripts_video.py | 33 +-- server/tests/test_pipeline_main_file.py | 1 + server/tests/test_security_permissions.py | 8 +- .../tests/test_transcripts_audio_download.py | 8 +- .../test_transcripts_audio_token_auth.py | 74 +++++- server/tests/test_transcripts_video.py | 159 ++++++++++++ www/app/(app)/rooms/page.tsx | 245 +++++++++++------- .../(app)/transcripts/[transcriptId]/page.tsx | 9 +- www/app/(app)/transcripts/new/page.tsx | 22 +- www/app/(app)/transcripts/shareAndPrivacy.tsx | 16 +- www/app/(app)/transcripts/shareEmail.tsx | 110 ++++++++ www/app/(app)/transcripts/useMp3.ts | 4 +- www/app/(app)/transcripts/videoPlayer.tsx | 15 +- .../components/SearchableLanguageSelect.tsx | 2 +- www/app/lib/apiHooks.ts | 18 +- www/app/reflector-api.d.ts | 114 ++++++++ 28 files changed, 861 insertions(+), 174 deletions(-) create mode 100644 server/migrations/versions/b4c7e8f9a012_add_email_transcript_to_to_room.py create mode 100644 server/reflector/views/config.py create mode 100644 www/app/(app)/transcripts/shareEmail.tsx diff --git a/server/migrations/versions/b4c7e8f9a012_add_email_transcript_to_to_room.py b/server/migrations/versions/b4c7e8f9a012_add_email_transcript_to_to_room.py new file mode 100644 index 00000000..e3e94ea5 --- /dev/null +++ b/server/migrations/versions/b4c7e8f9a012_add_email_transcript_to_to_room.py @@ -0,0 +1,28 @@ +"""add email_transcript_to to room + +Revision ID: b4c7e8f9a012 +Revises: a2b3c4d5e6f7 +Create Date: 2026-03-24 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b4c7e8f9a012" +down_revision: Union[str, None] = "a2b3c4d5e6f7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "room", + sa.Column("email_transcript_to", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("room", "email_transcript_to") diff --git a/server/reflector/app.py b/server/reflector/app.py index 4b8243bd..1dd88c48 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -13,6 +13,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup from reflector.logger import logger from reflector.metrics import metrics_init from reflector.settings import settings +from reflector.views.config import router as config_router from reflector.views.daily import router as daily_router from reflector.views.meetings import router as meetings_router from reflector.views.rooms import router as rooms_router @@ -107,6 +108,7 @@ app.include_router(transcripts_process_router, prefix="/v1") app.include_router(user_router, prefix="/v1") app.include_router(user_api_keys_router, prefix="/v1") app.include_router(user_ws_router, prefix="/v1") +app.include_router(config_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1") app.include_router(daily_router, prefix="/v1/daily") diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index 92ac5eac..80922af9 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -63,6 +63,7 @@ rooms = sqlalchemy.Table( nullable=False, server_default=sqlalchemy.sql.false(), ), + sqlalchemy.Column("email_transcript_to", sqlalchemy.String, nullable=True), sqlalchemy.Index("idx_room_is_shared", "is_shared"), sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), ) @@ -92,6 +93,7 @@ class Room(BaseModel): ics_last_etag: str | None = None platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM) skip_consent: bool = False + email_transcript_to: str | None = None class RoomController: @@ -147,6 +149,7 @@ class RoomController: ics_enabled: bool = False, platform: Platform = settings.DEFAULT_VIDEO_PLATFORM, skip_consent: bool = False, + email_transcript_to: str | None = None, ): """ Add a new room @@ -172,6 +175,7 @@ class RoomController: "ics_enabled": ics_enabled, "platform": platform, "skip_consent": skip_consent, + "email_transcript_to": email_transcript_to, } room = Room(**room_data) diff --git a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py index 5fe36b96..35a1003b 100644 --- a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py +++ b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py @@ -1501,13 +1501,30 @@ async def send_email(input: PipelineInput, ctx: Context) -> EmailResult: if recording and recording.meeting_id: meeting = await meetings_controller.get_by_id(recording.meeting_id) - if not meeting or not meeting.email_recipients: + recipients = ( + list(meeting.email_recipients) + if meeting and meeting.email_recipients + else [] + ) + + # Also check room-level email + from reflector.db.rooms import rooms_controller # noqa: PLC0415 + + if transcript.room_id: + room = await rooms_controller.get_by_id(transcript.room_id) + if room and room.email_transcript_to: + if room.email_transcript_to not in recipients: + recipients.append(room.email_transcript_to) + + if not recipients: ctx.log("send_email skipped (no email recipients)") return EmailResult(skipped=True) - await transcripts_controller.update(transcript, {"share_mode": "public"}) + # For room-level emails, do NOT change share_mode (only set public if meeting had recipients) + if meeting and meeting.email_recipients: + await transcripts_controller.update(transcript, {"share_mode": "public"}) - count = await send_transcript_email(meeting.email_recipients, transcript) + count = await send_transcript_email(recipients, transcript) ctx.log(f"send_email complete: sent {count} emails") return EmailResult(emails_sent=count) diff --git a/server/reflector/hatchet/workflows/file_pipeline.py b/server/reflector/hatchet/workflows/file_pipeline.py index 4b0b528e..5bd5caed 100644 --- a/server/reflector/hatchet/workflows/file_pipeline.py +++ b/server/reflector/hatchet/workflows/file_pipeline.py @@ -896,14 +896,30 @@ async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult: if recording and recording.meeting_id: meeting = await meetings_controller.get_by_id(recording.meeting_id) - if not meeting or not meeting.email_recipients: + recipients = ( + list(meeting.email_recipients) + if meeting and meeting.email_recipients + else [] + ) + + # Also check room-level email + from reflector.db.rooms import rooms_controller # noqa: PLC0415 + + if transcript.room_id: + room = await rooms_controller.get_by_id(transcript.room_id) + if room and room.email_transcript_to: + if room.email_transcript_to not in recipients: + recipients.append(room.email_transcript_to) + + if not recipients: ctx.log("send_email skipped (no email recipients)") return EmailResult(skipped=True) - # Set transcript to public so the link works for anyone - await transcripts_controller.update(transcript, {"share_mode": "public"}) + # For room-level emails, do NOT change share_mode (only set public if meeting had recipients) + if meeting and meeting.email_recipients: + await transcripts_controller.update(transcript, {"share_mode": "public"}) - count = await send_transcript_email(meeting.email_recipients, transcript) + count = await send_transcript_email(recipients, transcript) ctx.log(f"send_email complete: sent {count} emails") return EmailResult(emails_sent=count) diff --git a/server/reflector/hatchet/workflows/live_post_pipeline.py b/server/reflector/hatchet/workflows/live_post_pipeline.py index 2de144df..e1768835 100644 --- a/server/reflector/hatchet/workflows/live_post_pipeline.py +++ b/server/reflector/hatchet/workflows/live_post_pipeline.py @@ -397,13 +397,30 @@ async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult: if recording and recording.meeting_id: meeting = await meetings_controller.get_by_id(recording.meeting_id) - if not meeting or not meeting.email_recipients: + recipients = ( + list(meeting.email_recipients) + if meeting and meeting.email_recipients + else [] + ) + + # Also check room-level email + from reflector.db.rooms import rooms_controller # noqa: PLC0415 + + if transcript.room_id: + room = await rooms_controller.get_by_id(transcript.room_id) + if room and room.email_transcript_to: + if room.email_transcript_to not in recipients: + recipients.append(room.email_transcript_to) + + if not recipients: ctx.log("send_email skipped (no email recipients)") return EmailResult(skipped=True) - await transcripts_controller.update(transcript, {"share_mode": "public"}) + # For room-level emails, do NOT change share_mode (only set public if meeting had recipients) + if meeting and meeting.email_recipients: + await transcripts_controller.update(transcript, {"share_mode": "public"}) - count = await send_transcript_email(meeting.email_recipients, transcript) + count = await send_transcript_email(recipients, transcript) ctx.log(f"send_email complete: sent {count} emails") return EmailResult(emails_sent=count) diff --git a/server/reflector/storage/base.py b/server/reflector/storage/base.py index ba4316d8..9b8f2965 100644 --- a/server/reflector/storage/base.py +++ b/server/reflector/storage/base.py @@ -116,9 +116,12 @@ class Storage: expires_in: int = 3600, *, bucket: str | None = None, + extra_params: dict | None = None, ) -> str: """Generate presigned URL. bucket: override instance default if provided.""" - return await self._get_file_url(filename, operation, expires_in, bucket=bucket) + return await self._get_file_url( + filename, operation, expires_in, bucket=bucket, extra_params=extra_params + ) async def _get_file_url( self, @@ -127,6 +130,7 @@ class Storage: expires_in: int = 3600, *, bucket: str | None = None, + extra_params: dict | None = None, ) -> str: raise NotImplementedError diff --git a/server/reflector/storage/storage_aws.py b/server/reflector/storage/storage_aws.py index 66d7ccae..02f9bf1e 100644 --- a/server/reflector/storage/storage_aws.py +++ b/server/reflector/storage/storage_aws.py @@ -170,16 +170,23 @@ class AwsStorage(Storage): expires_in: int = 3600, *, bucket: str | None = None, + extra_params: dict | None = None, ) -> str: actual_bucket = bucket or self._bucket_name folder = self.aws_folder s3filename = f"{folder}/{filename}" if folder else filename + params = {} + if extra_params: + params.update(extra_params) + # Always set Bucket/Key after extra_params to prevent overrides + params["Bucket"] = actual_bucket + params["Key"] = s3filename async with self.session.client( "s3", config=self.boto_config, endpoint_url=self._endpoint_url ) as client: presigned_url = await client.generate_presigned_url( operation, - Params={"Bucket": actual_bucket, "Key": s3filename}, + Params=params, ExpiresIn=expires_in, ) diff --git a/server/reflector/views/config.py b/server/reflector/views/config.py new file mode 100644 index 00000000..6a8ed87a --- /dev/null +++ b/server/reflector/views/config.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter +from pydantic import BaseModel + +from reflector.email import is_email_configured +from reflector.settings import settings + +router = APIRouter() + + +class ConfigResponse(BaseModel): + zulip_enabled: bool + email_enabled: bool + + +@router.get("/config", response_model=ConfigResponse) +async def get_config(): + return ConfigResponse( + zulip_enabled=bool(settings.ZULIP_REALM), + email_enabled=is_email_configured(), + ) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 33ec2061..7a51ef41 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -44,6 +44,7 @@ class Room(BaseModel): ics_last_etag: Optional[str] = None platform: Platform skip_consent: bool = False + email_transcript_to: str | None = None class RoomDetails(Room): @@ -93,6 +94,7 @@ class CreateRoom(BaseModel): ics_enabled: bool = False platform: Platform skip_consent: bool = False + email_transcript_to: str | None = None class UpdateRoom(BaseModel): @@ -112,6 +114,7 @@ class UpdateRoom(BaseModel): ics_enabled: Optional[bool] = None platform: Optional[Platform] = None skip_consent: Optional[bool] = None + email_transcript_to: Optional[str] = None class CreateRoomMeeting(BaseModel): @@ -253,6 +256,7 @@ async def rooms_create( ics_enabled=room.ics_enabled, platform=room.platform, skip_consent=room.skip_consent, + email_transcript_to=room.email_transcript_to, ) diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 33342578..c33b1cb0 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -40,6 +40,7 @@ from reflector.db.transcripts import ( transcripts_controller, ) from reflector.db.users import user_controller +from reflector.email import is_email_configured, send_transcript_email from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Word from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment @@ -718,3 +719,31 @@ async def transcript_post_to_zulip( await transcripts_controller.update( transcript, {"zulip_message_id": response["id"]} ) + + +class SendEmailRequest(BaseModel): + email: str + + +class SendEmailResponse(BaseModel): + sent: int + + +@router.post("/transcripts/{transcript_id}/email", response_model=SendEmailResponse) +async def transcript_send_email( + transcript_id: str, + request: SendEmailRequest, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + if not is_email_configured(): + raise HTTPException(status_code=400, detail="Email not configured") + user_id = user["sub"] + transcript = await transcripts_controller.get_by_id_for_http( + transcript_id, user_id=user_id + ) + if not transcript: + raise HTTPException(status_code=404, detail="Transcript not found") + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") + sent = await send_transcript_email([request.email], transcript) + return SendEmailResponse(sent=sent) diff --git a/server/reflector/views/transcripts_audio.py b/server/reflector/views/transcripts_audio.py index f6dc2c2c..badd1cf9 100644 --- a/server/reflector/views/transcripts_audio.py +++ b/server/reflector/views/transcripts_audio.py @@ -53,9 +53,22 @@ async def transcript_get_audio_mp3( else: user_id = token_user["sub"] - transcript = await transcripts_controller.get_by_id_for_http( - transcript_id, user_id=user_id - ) + if not user_id and not token: + # No authentication provided at all. Only anonymous transcripts + # (user_id=None) are accessible without auth, to preserve + # pipeline access via _generate_local_audio_link(). + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript or transcript.deleted_at is not None: + raise HTTPException(status_code=404, detail="Transcript not found") + if transcript.user_id is not None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + else: + transcript = await transcripts_controller.get_by_id_for_http( + transcript_id, user_id=user_id + ) if transcript.audio_location == "storage": # proxy S3 file, to prevent issue with CORS @@ -94,16 +107,16 @@ async def transcript_get_audio_mp3( request, transcript.audio_mp3_filename, content_type="audio/mpeg", - content_disposition=f"attachment; filename={filename}", + content_disposition=f"inline; filename={filename}", ) @router.get("/transcripts/{transcript_id}/audio/waveform") async def transcript_get_audio_waveform( transcript_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> AudioWaveform: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) diff --git a/server/reflector/views/transcripts_video.py b/server/reflector/views/transcripts_video.py index aa698b1c..a85c5741 100644 --- a/server/reflector/views/transcripts_video.py +++ b/server/reflector/views/transcripts_video.py @@ -2,16 +2,14 @@ Transcript cloud video endpoint — returns a presigned URL for streaming playback. """ -from typing import Annotated, Optional +from typing import Annotated -import jwt -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel import reflector.auth as auth from reflector.db.meetings import meetings_controller from reflector.db.transcripts import transcripts_controller -from reflector.settings import settings from reflector.storage import get_source_storage router = APIRouter() @@ -30,26 +28,9 @@ class VideoUrlResponse(BaseModel): ) async def transcript_get_video_url( transcript_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], - token: str | None = None, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None - if not user_id and token: - try: - token_user = await auth.verify_raw_token(token) - except Exception: - token_user = None - if not token_user: - try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) - user_id = payload.get("sub") - except jwt.PyJWTError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired token", - ) - else: - user_id = token_user["sub"] + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id @@ -66,7 +47,11 @@ async def transcript_get_video_url( url = await source_storage.get_file_url( meeting.daily_composed_video_s3_key, operation="get_object", - expires_in=3600, + expires_in=900, + extra_params={ + "ResponseContentDisposition": "inline", + "ResponseContentType": "video/mp4", + }, ) return VideoUrlResponse( diff --git a/server/tests/test_pipeline_main_file.py b/server/tests/test_pipeline_main_file.py index 342e8288..e9fd9397 100644 --- a/server/tests/test_pipeline_main_file.py +++ b/server/tests/test_pipeline_main_file.py @@ -137,6 +137,7 @@ async def mock_storage(): operation: str = "get_object", expires_in: int = 3600, bucket=None, + extra_params=None, ): return f"http://test-storage/{path}" diff --git a/server/tests/test_security_permissions.py b/server/tests/test_security_permissions.py index 83d03223..bf8e048b 100644 --- a/server/tests/test_security_permissions.py +++ b/server/tests/test_security_permissions.py @@ -373,9 +373,9 @@ async def test_audio_mp3_requires_token_for_owned_transcript( tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True) shutil.copy(audio_path, tr.audio_mp3_filename) - # Anonymous GET without token should be 403 or 404 depending on access; we call mp3 + # Anonymous GET without token should be 401 (auth required) resp = await client.get(f"/transcripts/{t.id}/audio/mp3") - assert resp.status_code == 403 + assert resp.status_code == 401 # With token should succeed token = create_access_token( @@ -898,7 +898,7 @@ async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch @pytest.mark.asyncio async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir): """Anonymous transcript audio (mp3) is accessible without authentication - because user_id=None bypasses share_mode checks.""" + because user_id=None bypasses the auth requirement (pipeline access).""" monkeypatch.setattr(settings, "PUBLIC_MODE", True) monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix()) @@ -920,7 +920,7 @@ async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir resp = await client.get(f"/transcripts/{t.id}/audio/mp3") assert ( resp.status_code == 200 - ), f"Anonymous transcript audio should be accessible: {resp.text}" + ), f"Anonymous transcript audio should be accessible for pipeline: {resp.text}" @pytest.mark.asyncio diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py index 3990c553..132b43e8 100644 --- a/server/tests/test_transcripts_audio_download.py +++ b/server/tests/test_transcripts_audio_download.py @@ -40,7 +40,7 @@ async def fake_transcript(tmpdir, client, monkeypatch): ], ) async def test_transcript_audio_download( - fake_transcript, url_suffix, content_type, client + authenticated_client, fake_transcript, url_suffix, content_type, client ): response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}") assert response.status_code == 200 @@ -61,7 +61,7 @@ async def test_transcript_audio_download( ], ) async def test_transcript_audio_download_head( - fake_transcript, url_suffix, content_type, client + authenticated_client, fake_transcript, url_suffix, content_type, client ): response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}") assert response.status_code == 200 @@ -82,7 +82,7 @@ async def test_transcript_audio_download_head( ], ) async def test_transcript_audio_download_range( - fake_transcript, url_suffix, content_type, client + authenticated_client, fake_transcript, url_suffix, content_type, client ): response = await client.get( f"/transcripts/{fake_transcript.id}/audio{url_suffix}", @@ -102,7 +102,7 @@ async def test_transcript_audio_download_range( ], ) async def test_transcript_audio_download_range_with_seek( - fake_transcript, url_suffix, content_type, client + authenticated_client, fake_transcript, url_suffix, content_type, client ): response = await client.get( f"/transcripts/{fake_transcript.id}/audio{url_suffix}", diff --git a/server/tests/test_transcripts_audio_token_auth.py b/server/tests/test_transcripts_audio_token_auth.py index 0cc11dd5..8fb30171 100644 --- a/server/tests/test_transcripts_audio_token_auth.py +++ b/server/tests/test_transcripts_audio_token_auth.py @@ -98,10 +98,10 @@ async def private_transcript(tmpdir): @pytest.mark.asyncio -async def test_audio_mp3_private_no_auth_returns_403(private_transcript, client): - """Without auth, accessing a private transcript's audio returns 403.""" +async def test_audio_mp3_private_no_auth_returns_401(private_transcript, client): + """Without auth, accessing a private transcript's audio returns 401.""" response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3") - assert response.status_code == 403 + assert response.status_code == 401 @pytest.mark.asyncio @@ -125,8 +125,8 @@ async def test_audio_mp3_with_bearer_header(private_transcript, client): @pytest.mark.asyncio -async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client): - """Public transcripts are accessible without any auth.""" +async def test_audio_mp3_public_transcript_no_auth_returns_401(tmpdir, client): + """Public transcripts require authentication for audio access.""" from reflector.db.transcripts import SourceKind, transcripts_controller from reflector.settings import settings @@ -146,8 +146,7 @@ async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client): shutil.copy(mp3_source, audio_filename) response = await client.get(f"/transcripts/{transcript.id}/audio/mp3") - assert response.status_code == 200 - assert response.headers["content-type"] == "audio/mpeg" + assert response.status_code == 401 # --------------------------------------------------------------------------- @@ -299,11 +298,9 @@ async def test_local_audio_link_token_works_with_authentik_backend( """_generate_local_audio_link creates an HS256 token via create_access_token. When the Authentik (RS256) auth backend is active, verify_raw_token uses - JWTAuth which expects RS256 + public key. The HS256 token created by - _generate_local_audio_link will fail verification, returning 401. - - This test documents the bug: the internal audio URL generated for the - diarization pipeline is unusable under the JWT auth backend. + JWTAuth which expects RS256 + public key. The HS256 token fails RS256 + verification, but the audio endpoint's HS256 fallback (jwt.decode with + SECRET_KEY) correctly handles it, so the request succeeds with 200. """ from urllib.parse import parse_qs, urlparse @@ -322,6 +319,55 @@ async def test_local_audio_link_token_works_with_authentik_backend( f"/transcripts/{private_transcript.id}/audio/mp3?token={token}" ) - # BUG: this should be 200 (the token was created by our own server), - # but the Authentik backend rejects it because it's HS256, not RS256. + # The HS256 fallback in the audio endpoint handles this correctly. assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Waveform endpoint auth tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_waveform_requires_authentication(client): + """Waveform endpoint returns 401 for unauthenticated requests.""" + response = await client.get("/transcripts/any-id/audio/waveform") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_audio_mp3_authenticated_user_accesses_anonymous_transcript( + tmpdir, client +): + """Authenticated user can access audio for an anonymous (user_id=None) transcript.""" + from reflector.app import app + from reflector.auth import current_user, current_user_optional + from reflector.db.transcripts import SourceKind, transcripts_controller + from reflector.settings import settings + + settings.DATA_DIR = Path(tmpdir) + + transcript = await transcripts_controller.add( + "Anonymous audio test", + source_kind=SourceKind.FILE, + user_id=None, + share_mode="private", + ) + await transcripts_controller.update(transcript, {"status": "ended"}) + + audio_filename = transcript.audio_mp3_filename + mp3_source = Path(__file__).parent / "records" / "test_mathieu_hello.mp3" + audio_filename.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(mp3_source, audio_filename) + + _user = lambda: {"sub": "some-authenticated-user", "email": "user@example.com"} + app.dependency_overrides[current_user] = _user + app.dependency_overrides[current_user_optional] = _user + try: + response = await client.get(f"/transcripts/{transcript.id}/audio/mp3") + finally: + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] + + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/mpeg" diff --git a/server/tests/test_transcripts_video.py b/server/tests/test_transcripts_video.py index f01fd1b5..f04913c9 100644 --- a/server/tests/test_transcripts_video.py +++ b/server/tests/test_transcripts_video.py @@ -103,3 +103,162 @@ async def test_transcript_get_includes_video_fields(authenticated_client, client data = response.json() assert data["has_cloud_video"] is False assert data["cloud_video_duration"] is None + + +@pytest.mark.asyncio +async def test_video_url_requires_authentication(client): + """Test that video URL endpoint returns 401 for unauthenticated requests.""" + response = await client.get("/transcripts/any-id/video/url") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_video_url_presigned_params(authenticated_client, client): + """Test that presigned URL is generated with short expiry and inline disposition.""" + from reflector.db import get_database + from reflector.db.meetings import meetings + + meeting_id = "test-meeting-presigned-params" + await get_database().execute( + meetings.insert().values( + id=meeting_id, + room_name="Presigned Params Meeting", + room_url="https://example.com", + host_room_url="https://example.com/host", + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc) + timedelta(hours=1), + room_id=None, + daily_composed_video_s3_key="recordings/video.mp4", + daily_composed_video_duration=60, + ) + ) + + transcript = await transcripts_controller.add( + name="presigned-params", + source_kind=SourceKind.ROOM, + meeting_id=meeting_id, + user_id="randomuserid", + ) + + with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage: + mock_instance = AsyncMock() + mock_instance.get_file_url = AsyncMock( + return_value="https://s3.example.com/presigned-url" + ) + mock_storage.return_value = mock_instance + + await client.get(f"/transcripts/{transcript.id}/video/url") + + mock_instance.get_file_url.assert_called_once_with( + "recordings/video.mp4", + operation="get_object", + expires_in=900, + extra_params={ + "ResponseContentDisposition": "inline", + "ResponseContentType": "video/mp4", + }, + ) + + +async def _create_meeting_with_video(meeting_id): + """Helper to create a meeting with cloud video.""" + from reflector.db import get_database + from reflector.db.meetings import meetings + + await get_database().execute( + meetings.insert().values( + id=meeting_id, + room_name="Video Meeting", + room_url="https://example.com", + host_room_url="https://example.com/host", + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc) + timedelta(hours=1), + room_id=None, + daily_composed_video_s3_key="recordings/video.mp4", + daily_composed_video_duration=60, + ) + ) + + +@pytest.mark.asyncio +async def test_video_url_private_transcript_denies_non_owner( + authenticated_client, client +): + """Authenticated non-owner cannot access video for a private transcript.""" + meeting_id = "test-meeting-private-deny" + await _create_meeting_with_video(meeting_id) + + transcript = await transcripts_controller.add( + name="private-video", + source_kind=SourceKind.ROOM, + meeting_id=meeting_id, + user_id="other-owner", + share_mode="private", + ) + + with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage: + mock_instance = AsyncMock() + mock_instance.get_file_url = AsyncMock( + return_value="https://s3.example.com/url" + ) + mock_storage.return_value = mock_instance + + response = await client.get(f"/transcripts/{transcript.id}/video/url") + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_video_url_public_transcript_allows_authenticated_non_owner( + authenticated_client, client +): + """Authenticated non-owner can access video for a public transcript.""" + meeting_id = "test-meeting-public-allow" + await _create_meeting_with_video(meeting_id) + + transcript = await transcripts_controller.add( + name="public-video", + source_kind=SourceKind.ROOM, + meeting_id=meeting_id, + user_id="other-owner", + share_mode="public", + ) + + with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage: + mock_instance = AsyncMock() + mock_instance.get_file_url = AsyncMock( + return_value="https://s3.example.com/url" + ) + mock_storage.return_value = mock_instance + + response = await client.get(f"/transcripts/{transcript.id}/video/url") + + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_video_url_semi_private_allows_authenticated_non_owner( + authenticated_client, client +): + """Authenticated non-owner can access video for a semi-private transcript.""" + meeting_id = "test-meeting-semi-private-allow" + await _create_meeting_with_video(meeting_id) + + transcript = await transcripts_controller.add( + name="semi-private-video", + source_kind=SourceKind.ROOM, + meeting_id=meeting_id, + user_id="other-owner", + share_mode="semi-private", + ) + + with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage: + mock_instance = AsyncMock() + mock_instance.get_file_url = AsyncMock( + return_value="https://s3.example.com/url" + ) + mock_storage.return_value = mock_instance + + response = await client.get(f"/transcripts/{transcript.id}/video/url") + + assert response.status_code == 200 diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index e5349bab..7c81f3fe 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -31,6 +31,7 @@ import { useZulipTopics, useRoomGet, useRoomTestWebhook, + useConfig, } from "../../lib/apiHooks"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; @@ -92,6 +93,7 @@ const roomInitialState = { icsFetchInterval: 5, platform: "whereby", skipConsent: false, + emailTranscriptTo: "", }; export default function RoomsList() { @@ -133,11 +135,15 @@ export default function RoomsList() { null, ); const [showWebhookSecret, setShowWebhookSecret] = useState(false); + const [emailTranscriptEnabled, setEmailTranscriptEnabled] = useState(false); const createRoomMutation = useRoomCreate(); const updateRoomMutation = useRoomUpdate(); const deleteRoomMutation = useRoomDelete(); - const { data: streams = [] } = useZulipStreams(); + const { data: config } = useConfig(); + const zulipEnabled = config?.zulip_enabled ?? false; + const emailEnabled = config?.email_enabled ?? false; + const { data: streams = [] } = useZulipStreams(zulipEnabled); const { data: topics = [] } = useZulipTopics(selectedStreamId); const { @@ -177,6 +183,7 @@ export default function RoomsList() { icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5, platform: detailedEditedRoom.platform, skipConsent: detailedEditedRoom.skip_consent || false, + emailTranscriptTo: detailedEditedRoom.email_transcript_to || "", } : null, [detailedEditedRoom], @@ -329,6 +336,7 @@ export default function RoomsList() { ics_fetch_interval: room.icsFetchInterval, platform, skip_consent: room.skipConsent, + email_transcript_to: room.emailTranscriptTo || null, }; if (isEditing) { @@ -369,6 +377,7 @@ export default function RoomsList() { // Reset states setShowWebhookSecret(false); setWebhookTestResult(null); + setEmailTranscriptEnabled(!!roomData.email_transcript_to); setRoomInput({ name: roomData.name, @@ -392,6 +401,7 @@ export default function RoomsList() { icsFetchInterval: roomData.ics_fetch_interval || 5, platform: roomData.platform, skipConsent: roomData.skip_consent || false, + emailTranscriptTo: roomData.email_transcript_to || "", }); setEditRoomId(roomId); setIsEditing(true); @@ -469,6 +479,7 @@ export default function RoomsList() { setNameError(""); setShowWebhookSecret(false); setWebhookTestResult(null); + setEmailTranscriptEnabled(false); onOpen(); }} > @@ -504,7 +515,9 @@ export default function RoomsList() { General Calendar - Share + {(zulipEnabled || emailEnabled) && ( + Share + )} WebHook @@ -831,96 +844,144 @@ export default function RoomsList() { - - { - const syntheticEvent = { - target: { - name: "zulipAutoPost", - type: "checkbox", - checked: e.checked, - }, - }; - handleRoomChange(syntheticEvent); - }} - > - - - - - - Automatically post transcription to Zulip - - - - - Zulip stream - - setRoomInput({ - ...room, - zulipStream: e.value[0], - zulipTopic: "", - }) - } - collection={streamCollection} - disabled={!room.zulipAutoPost} - > - - - - - - - - - - - - {streamOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Zulip topic - - setRoomInput({ ...room, zulipTopic: e.value[0] }) - } - collection={topicCollection} - disabled={!room.zulipAutoPost} - > - - - - - - - - - - - - {topicOptions.map((option) => ( - - {option.label} - - - ))} - - - - + {emailEnabled && ( + <> + + { + setEmailTranscriptEnabled(!!e.checked); + if (!e.checked) { + setRoomInput({ + ...room, + emailTranscriptTo: "", + }); + } + }} + > + + + + + + Email me transcript when processed + + + + {emailTranscriptEnabled && ( + + + + Transcript will be emailed to this address after + processing + + + )} + + )} + {zulipEnabled && ( + <> + + { + const syntheticEvent = { + target: { + name: "zulipAutoPost", + type: "checkbox", + checked: e.checked, + }, + }; + handleRoomChange(syntheticEvent); + }} + > + + + + + + Automatically post transcription to Zulip + + + + + Zulip stream + + setRoomInput({ + ...room, + zulipStream: e.value[0], + zulipTopic: "", + }) + } + collection={streamCollection} + disabled={!room.zulipAutoPost} + > + + + + + + + + + + + + {streamOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + Zulip topic + + setRoomInput({ + ...room, + zulipTopic: e.value[0], + }) + } + collection={topicCollection} + disabled={!room.zulipAutoPost} + > + + + + + + + + + + + + {topicOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + )} diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx index b02f77c7..be06f642 100644 --- a/www/app/(app)/transcripts/[transcriptId]/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx @@ -24,6 +24,8 @@ import { } from "@chakra-ui/react"; import { useTranscriptGet } from "../../../lib/apiHooks"; import { TranscriptStatus } from "../../../lib/transcript"; +import { useAuth } from "../../../lib/AuthProvider"; +import { featureEnabled } from "../../../lib/features"; type TranscriptDetails = { params: Promise<{ @@ -57,7 +59,10 @@ export default function TranscriptDetails(details: TranscriptDetails) { const [finalSummaryElement, setFinalSummaryElement] = useState(null); - const hasCloudVideo = !!transcript.data?.has_cloud_video; + const auth = useAuth(); + const isAuthenticated = + auth.status === "authenticated" || !featureEnabled("requireLogin"); + const hasCloudVideo = !!transcript.data?.has_cloud_video && isAuthenticated; const [videoExpanded, setVideoExpanded] = useState(false); const [videoNewBadge, setVideoNewBadge] = useState(() => { if (typeof window === "undefined") return true; @@ -145,7 +150,7 @@ export default function TranscriptDetails(details: TranscriptDetails) { mt={4} mb={4} > - {!mp3.audioDeleted && ( + {isAuthenticated && !mp3.audioDeleted && ( <> {waveform.waveform && mp3.media && topics.topics ? ( l.value && l.value !== "NOTRANSLATION", +); + const TranscriptCreate = () => { const router = useRouter(); const auth = useAuth(); @@ -33,8 +37,13 @@ const TranscriptCreate = () => { const nameChange = (event: React.ChangeEvent) => { setName(event.target.value); }; + const [sourceLanguage, setSourceLanguage] = useState(""); const [targetLanguage, setTargetLanguage] = useState("NOTRANSLATION"); + const onSourceLanguageChange = (newval) => { + (!newval || typeof newval === "string") && + setSourceLanguage(newval || "en"); + }; const onLanguageChange = (newval) => { (!newval || typeof newval === "string") && setTargetLanguage(newval); }; @@ -55,7 +64,7 @@ const TranscriptCreate = () => { const targetLang = getTargetLanguage(); createTranscript.create({ name, - source_language: "en", + source_language: sourceLanguage || "en", target_language: targetLang || "en", source_kind: "live", }); @@ -67,7 +76,7 @@ const TranscriptCreate = () => { const targetLang = getTargetLanguage(); createTranscript.create({ name, - source_language: "en", + source_language: sourceLanguage || "en", target_language: targetLang || "en", source_kind: "file", }); @@ -160,6 +169,15 @@ const TranscriptCreate = () => { placeholder="Optional" /> + + Audio language + + Do you want to enable live translation? { const selectedOption = shareOptionsData.find( @@ -169,14 +173,20 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { Share options - - {requireLogin && ( + + {requireLogin && zulipEnabled && ( )} + {emailEnabled && ( + + )} { + if (!email) return; + try { + await sendEmailMutation.mutateAsync({ + params: { + path: { transcript_id: props.transcript.id }, + }, + body: { email }, + }); + setSent(true); + setTimeout(() => { + setSent(false); + setShowModal(false); + setEmail(""); + }, 2000); + } catch (error) { + console.error("Error sending email:", error); + } + }; + + return ( + <> + + + { + setShowModal(e.open); + if (!e.open) { + setSent(false); + setEmail(""); + } + }} + size="md" + > + + + + + Send Transcript via Email + + + + + + {sent ? ( + Email sent successfully! + ) : ( + + + Enter the email address to send this transcript to: + + setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + /> + + )} + + + + {!sent && ( + + )} + + + + + + ); +} diff --git a/www/app/(app)/transcripts/useMp3.ts b/www/app/(app)/transcripts/useMp3.ts index fef630c3..a93bbc75 100644 --- a/www/app/(app)/transcripts/useMp3.ts +++ b/www/app/(app)/transcripts/useMp3.ts @@ -56,7 +56,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { }, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]); useEffect(() => { - if (!transcriptId || later || !transcript) return; + if (!transcriptId || later || !transcript || !accessTokenInfo) return; let stopped = false; let audioElement: HTMLAudioElement | null = null; @@ -113,7 +113,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { if (handleError) audioElement.removeEventListener("error", handleError); } }; - }, [transcriptId, transcript, later]); + }, [transcriptId, transcript, later, accessTokenInfo]); const getNow = () => { setLater(false); diff --git a/www/app/(app)/transcripts/videoPlayer.tsx b/www/app/(app)/transcripts/videoPlayer.tsx index e68933d9..05fe4a1a 100644 --- a/www/app/(app)/transcripts/videoPlayer.tsx +++ b/www/app/(app)/transcripts/videoPlayer.tsx @@ -39,17 +39,16 @@ export default function VideoPlayer({ setLoading(true); setError(null); try { - const params = new URLSearchParams(); - if (accessToken) { - params.set("token", accessToken); - } - const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url?${params}`; + const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url`; const headers: Record = {}; if (accessToken) { headers["Authorization"] = `Bearer ${accessToken}`; } const resp = await fetch(url, { headers }); if (!resp.ok) { + if (resp.status === 401) { + throw new Error("Sign in to view the video recording"); + } throw new Error("Failed to load video"); } const data = await resp.json(); @@ -90,7 +89,7 @@ export default function VideoPlayer({ w="fit-content" maxW="100%" > - Failed to load video recording + {error || "Failed to load video recording"} ); } @@ -132,10 +131,14 @@ export default function VideoPlayer({ {/* Video element with visible controls */} + {/* eslint-disable-next-line jsx-a11y/media-has-caption */}