Skip to content

Commit 46594cc

Browse files
committed
api: Add routes getFileTemporaryUrl and tryGetFileTemporaryUrl
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 #1144 (comment)
1 parent caf1ddb commit 46594cc

File tree

2 files changed

+117
-0
lines changed

2 files changed

+117
-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: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,78 @@ 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/user_uploads/123/testfile.jpg'),
651+
realmUrl: connection.realmUrl);
652+
653+
check(result).isNull();
654+
check(connection.lastRequest).isNull();
655+
});
656+
});
657+
658+
test('returns null for non-matching URL pattern', () {
659+
return FakeApiConnection.with_((connection) async {
660+
final result = await tryGetFileTemporaryUrl(connection,
661+
url: Uri.parse('${connection.realmUrl}/invalid/path/file.jpg'),
662+
realmUrl: connection.realmUrl);
663+
664+
check(result).isNull();
665+
check(connection.lastRequest).isNull();
666+
});
667+
});
668+
669+
test('returns null when API request fails', () {
670+
return FakeApiConnection.with_((connection) async {
671+
connection.prepare(
672+
apiException: eg.apiBadRequest(message: 'Not found'));
673+
674+
final result = await tryGetFileTemporaryUrl(connection,
675+
url: Uri.parse('${connection.realmUrl}/user_uploads/1/2/testfile.jpg'),
676+
realmUrl: connection.realmUrl);
677+
678+
check(result).isNull();
679+
});
680+
});
681+
});
682+
611683
group('addReaction', () {
612684
Future<void> checkAddReaction(FakeApiConnection connection, {
613685
required int messageId,

0 commit comments

Comments
 (0)