Skip to content

Fix broken download for long hosted-meeting recordings#319

Draft
mikewillems wants to merge 8 commits intostagingfrom
mw/fix/hosted-recordings
Draft

Fix broken download for long hosted-meeting recordings#319
mikewillems wants to merge 8 commits intostagingfrom
mw/fix/hosted-recordings

Conversation

@mikewillems
Copy link
Collaborator

@mikewillems mikewillems commented Mar 4, 2026

What is in this PR?

Downloading recordings for long events either spun indefinitely or returned a user-facing error.

There were two potential causes, this PR fixes both.

Cause 1: The function was trying to do download by streaming synchronously (bless its heart).
Cause 2: Agora splits video downloads into a max size of the lesser of 2GB or 2 hours. This could leave a synchronous function hanging until the other download chunk is requested.

More technically:

downloadRecording streamed the entire ZIP file through the Cloud Function HTTP response, and the Flutter client read the full response body into memory before creating a blob URL. For long recordings the function hit its timeout mid-stream, the connection drops, and response.bodyBytes is null. Normally one would make the function async at that point, but instead the timeout was overridden with 5 minutes instead of 1 minute, which aside from being the wrong approach, still isn't long enough for long recordings, especially depending on network connection. I also don't know what kind of egress bandwidth we have on GCS.

The CORS error visible in devtools was just the signal that we didn't have a file to download.

Changes in the codebase

Replace ZIP streaming with signed GCS URLs.

The function now lists all MP4 files under {event.id}/ in GCS, generates a short-lived signed URL for each, and returns them as JSON. File bytes never pass through the function -- the whole call returns near-instantly regardless of recording length. The Flutter client renders each URL as a clickable link; the browser handles the download when the user taps it.

Commit 1 (caea101): switching download scheme to signed direct-from-GCS URL

firebase/functions/js/download-recordings.js

  • Removed ZIP streaming via archiver and the 300-second timeout override.
  • List MP4 files under {event.id}/, generate signed GCS URLs, return as { recordings: [{ name, url }] }.
  • Errors now return structured JSON instead of throwing HttpsError inside an onRequest handler (which does not propagate correctly outside onCall).

Commit 2 (bb80712): hygiene

firebase/functions/package.json

Removed unused archiver dependency

Commit 3 (6785311): client download initiation

client/lib/features/admin/presentation/views/events_tab.dart

  • Parse JSON response and open each signed URL via html.window.open().
  • Removed blob creation, anchor click, and URL revocation.
  • Added dart:convert import.

Commit 4 (e7a1fe9): hardening the download endpoint

firebase/functions/js/download-recordings.js

  • Shortened signed URL TTL from 24h to 15 minutes. 24h is longer than necessary and widens the window for a leaked URL to be abused; 15 minutes is long enough for a user to click the link but short enough to meaningfully limit exposure.
  • Added eventPathRegex validation before passing eventPath to firestore.doc(). Without it any caller with a valid auth token could supply an arbitrary Firestore path and read documents outside the events collection. The regex enforces the expected shape: community/{id}/templates/{id}/events/{id} (derived from the FirestoreDatabase.templateReference path builder and FirestoreEventService.eventsCollection).
  • Sort mp4Files by name before generating URLs so recordings are returned in a deterministic order regardless of GCS list ordering.

Commit 5 (e7df3f3): UI visual consistency, use tapped links instead of popups in case blocked

client/lib/features/admin/presentation/views/events_tab.dart

  • Replaced html.window.open() loop (called after an async gap, so popup blockers fire) with a stateful approach: the button press stores the returned URLs in _recordingLinks (a Map<String, List<Map<String, String>>> keyed by event ID), and _buildRecordingSection renders numbered GestureDetector links below the button once populated. Links persist on screen until the next navigation, and any subsequent button press refreshes them.
  • Added guards around jsonDecode (try/catch) and the recordings field (is! List check) to surface a clean user-facing error instead of a runtime cast exception if the server returns malformed JSON or an unexpected schema.
  • Swapped bare Text for HeightConstrainedText on the rendered links to match every other text element in the table, and removed the hardcoded fontSize: 12 (nothing in AppTextStyle uses 12; the smallest defined size is bodySmall at 14). Both changes bring the link style in line with the date link in the same row and other admin-view link patterns (Colors.blue + TextDecoration.underline).

Changes outside the codebase

None.

Testing this PR

For existing recordings (this should test it completely)

  1. Go to any long recording over ~1h and attempt to download it.

  2. Verify that the app doesn't freeze and that a new tab opens with the proper download.

  3. Do the same for a recording over ~2h (maybe 3?) and verify that you get two downloads.

  4. Start a hosted meeting with recording, keep it short.

  5. Download recording - verify it still works for short recordings, but opens in a new tab.

  6. Start a meeting with recording, let it run for 1h.

  7. End the meeting and download the recording. Verify that the app continues running and that a new tab opens.

  8. Start a meeting with recording, let it run for at least 3h. Verify that the app continues running, and TWO tabs open with two downloads (at this point Agora should split the according into two files per its docs).

Additional information

Note: This fixes downloads for the existing main-room hosted recording flow only. Per-session and breakout room download support is tracked in phases 4 and 5 of Breakout Room Recordings #9.

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

Visit the preview URL for this PR (updated for commit 31be640):

https://gen-hls-bkc-7627--pr319-mw-fix-hosted-record-g1depyz4.web.app

(expires Fri, 20 Mar 2026 17:52:45 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: eed668cca81618d491d024574a8f8a6003deaa8d

This comment was marked as resolved.

@mikewillems mikewillems changed the title Mw/fix/hosted recordings Fix broken download for long hosted-meeting recordings Mar 4, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • firebase/functions/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +129 to +137
final idToken =
await userService.firebaseAuth.currentUser?.getIdToken();

final response = await http.post(
Uri.parse(
'${Environment.functionsUrlPrefix}/downloadRecording',
),
headers: {'Authorization': 'Bearer $idToken'},
body: {'eventPath': event.fullPath},
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getIdToken() can return null when there is no current user; this code would send Authorization: Bearer null, which then fails server-side. Consider guarding on idToken == null and surfacing a clear auth error before making the request.

Copilot uses AI. Check for mistakes.
if (recordings.isEmpty) {
throw Exception('No recordings found for this event');
}

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setState(() => _recordingLinks[event.id] = recordings); runs after multiple awaits. If the user navigates away while the request is in flight, this can throw "setState() called after dispose()". Consider checking if (!mounted) return; before calling setState.

Suggested change
if (!mounted) return;

Copilot uses AI. Check for mistakes.
@mikewillems mikewillems marked this pull request as draft March 6, 2026 08:26
Copy link
Collaborator

@katherineqian katherineqian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to just submit the comments I have at this stage - I completed a review of the code and it looks good to me. Still need to re-test the behavior and the UI; please re-request when ready!

archive.on('error', (err) => {
console.error('Error creating zip file:', err)
})
const [files] = await bucket.getFiles({ prefix: `${event.id}/` })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So much neater!


var _numToShow = 10;

/// Signed GCS URLs returned for each event, keyed by event ID.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very helpful comment!

}

const bucket = storage.bucket(bucketName)
const [files] = await bucket.getFiles({ prefix: `${event.id}/` })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not related to the improvements you introduced, but it would have been nice to organize this bucket within folders under community/template for DX, since that's the way Firestore is currently organized. I know your plans for sensemaking also propose changes to structure here, though the proposal there was for the new path to add further levels of granularity beyond eventId.

There are definitely some concerns with backwards compatibility, though since there aren't a lot of recordings on prod currently, I think a one-time export of historical recordings for beta clients could work here if we want to reorganize. I'll follow up on Slack about this, since we should also chat about the new GCS recording path wrt breakout room recordings!

This comment is non-blocking for this PR - we can change the path in a subsequent PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants