From 3e388caefc239f4a5b02bd07f996939b6a617116 Mon Sep 17 00:00:00 2001 From: chimnayajith Date: Sat, 2 Aug 2025 23:59:58 +0530 Subject: [PATCH] api [nfc]: Add functions to get temporary URLs for files Implement `getFileTemporaryUrl` and `tryGetFileTemporaryUrl` to access user-uploaded files without requiring authentication. The temporary URLs remain valid for 60 seconds and provide a secure way to share files without exposing API keys. This uses the GET `/user_uploads/{realm_id}/{filename}` endpoint that returns a URL allowing immediate access without requiring authentication. Requested in https://github.com/zulip/zulip-flutter/pull/1144#issuecomment-3066438822 --- lib/api/route/messages.dart | 45 +++++++++++++++++++ test/api/route/messages_test.dart | 73 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 05364951cd..86b5e735cb 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -327,6 +327,51 @@ class UploadFileResult { Map toJson() => _$UploadFileResultToJson(this); } +/// Get a temporary, authless partial URL to a realm-uploaded file. +/// +/// The URL returned allows a file to be viewed without requiring authentication, +/// but it doesn't include secrets like the API key. This URL remains valid for +/// 60 seconds. +/// +/// This endpoint is documented in the OpenAPI description: +/// https://github.com/zulip/zulip/blob/main/zerver/openapi/zulip.yaml +/// under the name `get_file_temporary_url`. +Future getFileTemporaryUrl(ApiConnection connection, { + required String filePath, +}) async { + final response = await connection.get('getFileTemporaryUrl', + (json) => json['url'], + filePath.substring(1), // remove leading slash to avoid duplicate + {}, + ); + + return Uri.parse('${connection.realmUrl}$response'); +} + +/// A wrapper for [getFileTemporaryUrl] that returns null on failure. +/// +/// Validates that the URL is a realm-uploaded file before proceeding. +Future tryGetFileTemporaryUrl( + ApiConnection connection, { + required Uri url, + required Uri realmUrl, +}) async { + if (url.origin != realmUrl.origin) { + return null; + } + + final filePath = url.path; + if (!RegExp(r'^/user_uploads/[0-9]+/.+$').hasMatch(filePath)) { + return null; + } + + try { + return await getFileTemporaryUrl(connection, filePath: filePath); + } catch (e) { + return null; + } +} + /// https://zulip.com/api/add-reaction Future addReaction(ApiConnection connection, { required int messageId, diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index f00bf4428f..798f075d73 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -608,6 +608,79 @@ void main() { }); }); + group('getFileTemporaryUrl', () { + test('constructs URL correctly from response', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: { + 'url': '/user_uploads/temporary/abc123', + 'result': 'success', + 'msg': '', + }); + + final result = await getFileTemporaryUrl(connection, + filePath: '/user_uploads/1/2/testfile.jpg'); + + check(result.toString()).equals('${connection.realmUrl}/user_uploads/temporary/abc123'); + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/user_uploads/1/2/testfile.jpg'); + }); + }); + + test('returns temporary URL for valid realm file', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: { + 'url': '/user_uploads/temporary/abc123', + 'result': 'success', + 'msg': '', + }); + + final result = await tryGetFileTemporaryUrl(connection, + url: Uri.parse('${connection.realmUrl}user_uploads/123/testfile.jpg'), + realmUrl: connection.realmUrl); + + check(result).isNotNull(); + check(result.toString()).equals('${connection.realmUrl}/user_uploads/temporary/abc123'); + }); + }); + + test('returns null for non-realm URL', () { + return FakeApiConnection.with_((connection) async { + final result = await tryGetFileTemporaryUrl(connection, + url: Uri.parse('https://example.com/image.jpg'), + realmUrl: connection.realmUrl); + + check(result).isNull(); + // Verify no API calls were made + check(connection.lastRequest).isNull(); + }); + }); + + test('returns null for non-matching URL pattern', () { + return FakeApiConnection.with_((connection) async { + final result = await tryGetFileTemporaryUrl(connection, + url: Uri.parse('${connection.realmUrl}/invalid/path/file.jpg'), + realmUrl: connection.realmUrl); + + check(result).isNull(); + // Verify no API calls were made + check(connection.lastRequest).isNull(); + }); + }); + + test('returns null when API request fails', () { + return FakeApiConnection.with_((connection) async { + connection.prepare( + apiException: eg.apiBadRequest(message: 'Not found')); + + final result = await tryGetFileTemporaryUrl(connection, + url: Uri.parse('${connection.realmUrl}/user_uploads/1/2/testfile.jpg'), + realmUrl: connection.realmUrl); + + check(result).isNull(); + }); + }); + }); group('addReaction', () { Future checkAddReaction(FakeApiConnection connection, { required int messageId,