Skip to content

Commit 3e388ca

Browse files
committed
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 zulip#1144 (comment)
1 parent caf1ddb commit 3e388ca

File tree

2 files changed

+118
-0
lines changed

2 files changed

+118
-0
lines changed

lib/api/route/messages.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,51 @@ class UploadFileResult {
327327
Map<String, dynamic> toJson() => _$UploadFileResultToJson(this);
328328
}
329329

330+
/// Get a temporary, authless partial URL to a realm-uploaded file.
331+
///
332+
/// The URL returned allows a file to be viewed without requiring authentication,
333+
/// but it doesn't include secrets like the API key. This URL remains valid for
334+
/// 60 seconds.
335+
///
336+
/// This endpoint is documented in the OpenAPI description:
337+
/// https://github.com/zulip/zulip/blob/main/zerver/openapi/zulip.yaml
338+
/// under the name `get_file_temporary_url`.
339+
Future<Uri> getFileTemporaryUrl(ApiConnection connection, {
340+
required String filePath,
341+
}) async {
342+
final response = await connection.get('getFileTemporaryUrl',
343+
(json) => json['url'],
344+
filePath.substring(1), // remove leading slash to avoid duplicate
345+
{},
346+
);
347+
348+
return Uri.parse('${connection.realmUrl}$response');
349+
}
350+
351+
/// A wrapper for [getFileTemporaryUrl] that returns null on failure.
352+
///
353+
/// Validates that the URL is a realm-uploaded file before proceeding.
354+
Future<Uri?> tryGetFileTemporaryUrl(
355+
ApiConnection connection, {
356+
required Uri url,
357+
required Uri realmUrl,
358+
}) async {
359+
if (url.origin != realmUrl.origin) {
360+
return null;
361+
}
362+
363+
final filePath = url.path;
364+
if (!RegExp(r'^/user_uploads/[0-9]+/.+$').hasMatch(filePath)) {
365+
return null;
366+
}
367+
368+
try {
369+
return await getFileTemporaryUrl(connection, filePath: filePath);
370+
} catch (e) {
371+
return null;
372+
}
373+
}
374+
330375
/// https://zulip.com/api/add-reaction
331376
Future<void> addReaction(ApiConnection connection, {
332377
required int messageId,

test/api/route/messages_test.dart

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,79 @@ void main() {
608608
});
609609
});
610610

611+
group('getFileTemporaryUrl', () {
612+
test('constructs URL correctly from response', () {
613+
return FakeApiConnection.with_((connection) async {
614+
connection.prepare(json: {
615+
'url': '/user_uploads/temporary/abc123',
616+
'result': 'success',
617+
'msg': '',
618+
});
619+
620+
final result = await getFileTemporaryUrl(connection,
621+
filePath: '/user_uploads/1/2/testfile.jpg');
622+
623+
check(result.toString()).equals('${connection.realmUrl}/user_uploads/temporary/abc123');
624+
check(connection.lastRequest).isA<http.Request>()
625+
..method.equals('GET')
626+
..url.path.equals('/api/v1/user_uploads/1/2/testfile.jpg');
627+
});
628+
});
629+
630+
test('returns temporary URL for valid realm file', () {
631+
return FakeApiConnection.with_((connection) async {
632+
connection.prepare(json: {
633+
'url': '/user_uploads/temporary/abc123',
634+
'result': 'success',
635+
'msg': '',
636+
});
637+
638+
final result = await tryGetFileTemporaryUrl(connection,
639+
url: Uri.parse('${connection.realmUrl}user_uploads/123/testfile.jpg'),
640+
realmUrl: connection.realmUrl);
641+
642+
check(result).isNotNull();
643+
check(result.toString()).equals('${connection.realmUrl}/user_uploads/temporary/abc123');
644+
});
645+
});
646+
647+
test('returns null for non-realm URL', () {
648+
return FakeApiConnection.with_((connection) async {
649+
final result = await tryGetFileTemporaryUrl(connection,
650+
url: Uri.parse('https://example.com/image.jpg'),
651+
realmUrl: connection.realmUrl);
652+
653+
check(result).isNull();
654+
// Verify no API calls were made
655+
check(connection.lastRequest).isNull();
656+
});
657+
});
658+
659+
test('returns null for non-matching URL pattern', () {
660+
return FakeApiConnection.with_((connection) async {
661+
final result = await tryGetFileTemporaryUrl(connection,
662+
url: Uri.parse('${connection.realmUrl}/invalid/path/file.jpg'),
663+
realmUrl: connection.realmUrl);
664+
665+
check(result).isNull();
666+
// Verify no API calls were made
667+
check(connection.lastRequest).isNull();
668+
});
669+
});
670+
671+
test('returns null when API request fails', () {
672+
return FakeApiConnection.with_((connection) async {
673+
connection.prepare(
674+
apiException: eg.apiBadRequest(message: 'Not found'));
675+
676+
final result = await tryGetFileTemporaryUrl(connection,
677+
url: Uri.parse('${connection.realmUrl}/user_uploads/1/2/testfile.jpg'),
678+
realmUrl: connection.realmUrl);
679+
680+
check(result).isNull();
681+
});
682+
});
683+
});
611684
group('addReaction', () {
612685
Future<void> checkAddReaction(FakeApiConnection connection, {
613686
required int messageId,

0 commit comments

Comments
 (0)