Repository: zulip/zulip-flutter (language: dart)

Issue:
Open uploaded files without logging in again
Currently if you're looking at a Zulip message that contains an uploaded file (other than an image or video, which we handle directly in the message feed) — for example a PDF — and you click the link to try to open the file, you get a Zulip login page.

This ultimately works; you just have to do the login, and then you can see the file. But it'd be smoother if the file opened without that step; after all, you were already logged in in the Zulip app.

The legacy app had this feature; see https://github.com/zulip/zulip-mobile/issues/3303 where it was requested, and https://github.com/zulip/zulip-mobile/pull/4089 for the implementation. We'll use the same feature of the Zulip server API as the legacy app did for this; see [getFileTemporaryUrl.js](https://github.com/zulip/zulip-mobile/blob/eb8505c4a0ccfb7bdd817ca9022c11ce57ab69d8/src/api/messages/getFileTemporaryUrl.js).

### References
* Chat thread: [#mobile > can't open uploaded PDF](https://chat.zulip.org/#narrow/channel/48-mobile/topic/can't.20open.20uploaded.20PDF/with/2226578)
* Old zulip-mobile issue: zulip/zulip-mobile#3303
* Implementation in zulip-mobile: zulip/zulip-mobile#4089
* API documentation: partial docs at https://zulip.com/api/get-file-temporary-url ; additional details in zulip-mobile's API binding at [zulip-mobile:src/api/messages/getFileTemporaryUrl.js](https://github.com/zulip/zulip-mobile/blob/eb8505c4a0ccfb7bdd817ca9022c11ce57ab69d8/src/api/messages/getFileTemporaryUrl.js).


Discussion hints:
Describing this with different words to help with finding it in search (after #1834; thanks @varkor): you might say you're trying to open or download a file that's been shared in a Zulip message.


Pull request: Open uploaded files using authed temporary URLs
Fixes #1732.


Test command: flutter test test/model/internal_link_test.dart test/widgets/actions_test.dart test/widgets/content_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 9048fa98de..1f52ac32ba 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -329,6 +329,10 @@
   "@errorCouldNotFetchMessageSource": {
     "description": "Error message when the source of a message could not be fetched."
   },
+  "errorCouldNotAccessUploadedFileTitle": "Could not access uploaded file",
+  "@errorCouldNotAccessUploadedFileTitle": {
+    "description": "Error title on failure in opening a file someone previously uploaded to Zulip"
+  },
   "errorCopyingFailed": "Copying failed",
   "@errorCopyingFailed": {
     "description": "Error message when copying the text of a message to the user's system clipboard failed."
diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart
index 6d0487f0d7..73ecb02af9 100644
--- a/lib/api/route/messages.dart
+++ b/lib/api/route/messages.dart
@@ -285,6 +285,29 @@ class UploadFileResult {
   Map<String, dynamic> toJson() => _$UploadFileResultToJson(this);
 }
 
+/// https://zulip.com/api/get-file-temporary-url
+Future<GetFileTemporaryUrlResult> getFileTemporaryUrl(ApiConnection connection, {
+  required int realmId,
+  required String filename,
+}) {
+  return connection.get('getFileTemporaryUrl', GetFileTemporaryUrlResult.fromJson,
+    'user_uploads/$realmId/$filename', {});
+}
+
+@JsonSerializable(fieldRename: FieldRename.snake)
+class GetFileTemporaryUrlResult {
+  final String url;
+
+  GetFileTemporaryUrlResult({
+    required this.url,
+  });
+
+  factory GetFileTemporaryUrlResult.fromJson(Map<String, dynamic> json) =>
+    _$GetFileTemporaryUrlResultFromJson(json);
+
+  Map<String, dynamic> toJson() => _$GetFileTemporaryUrlResultToJson(this);
+}
+
 /// https://zulip.com/api/add-reaction
 Future<void> addReaction(ApiConnection connection, {
   required int messageId,
diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart
index 4169634701..bf07f1f153 100644
--- a/lib/api/route/messages.g.dart
+++ b/lib/api/route/messages.g.dart
@@ -55,6 +55,14 @@ UploadFileResult _$UploadFileResultFromJson(Map<String, dynamic> json) =>
 Map<String, dynamic> _$UploadFileResultToJson(UploadFileResult instance) =>
     <String, dynamic>{'uri': instance.url};
 
+GetFileTemporaryUrlResult _$GetFileTemporaryUrlResultFromJson(
+  Map<String, dynamic> json,
+) => GetFileTemporaryUrlResult(url: json['url'] as String);
+
+Map<String, dynamic> _$GetFileTemporaryUrlResultToJson(
+  GetFileTemporaryUrlResult instance,
+) => <String, dynamic>{'url': instance.url};
+
 UpdateMessageFlagsResult _$UpdateMessageFlagsResultFromJson(
   Map<String, dynamic> json,
 ) => UpdateMessageFlagsResult(
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 7a08e9d986..d1d40b2010 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -593,6 +593,12 @@ abstract class ZulipLocalizations {
   /// **'Could not fetch message source.'**
   String get errorCouldNotFetchMessageSource;
 
+  /// Error title on failure in opening a file someone previously uploaded to Zulip
+  ///
+  /// In en, this message translates to:
+  /// **'Could not access uploaded file'**
+  String get errorCouldNotAccessUploadedFileTitle;
+
   /// Error message when copying the text of a message to the user's system clipboard failed.
   ///
   /// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index 759cd06a01..1a292881ec 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -277,6 +277,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Could not fetch message source.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Copying failed';
 
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 4975e37387..83cc660772 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -285,6 +285,10 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Konnte Nachrichtenquelle nicht abrufen.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Kopieren fehlgeschlagen';
 
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 4ad9cf4f51..80c1c2c475 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -277,6 +277,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Could not fetch message source.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Copying failed';
 
diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 402619efec..7572aa5d3f 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -284,6 +284,10 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Impossible d\'atteindre le message source.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Échec de la copie';
 
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index ce18a1d568..e42ef1f08e 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -283,6 +283,10 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Impossibile recuperare l\'origine del messaggio.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Copia non riuscita';
 
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 4e75bf6568..eae3a0c3eb 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -272,6 +272,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get errorCouldNotFetchMessageSource => 'メッセージのソースを取得できませんでした。';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'コピーに失敗しました';
 
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 344bb0372b..cc2c5e1852 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -277,6 +277,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Could not fetch message source.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Copying failed';
 
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index 45e9fb2cac..c7c14cf77e 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -288,6 +288,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Nie można uzyskać źródłowej wiadomości.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Nie udało się skopiować';
 
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 817d6b03f8..ae8ce816c4 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -289,6 +289,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Не удалось извлечь источник сообщения.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Сбой копирования';
 
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 2504894b25..ca321e40a6 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -278,6 +278,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Nepodarilo sa nahrať zdroj správy';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Kopírovanie zlyhalo';
 
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 933971c93a..3e72b30aac 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -286,6 +286,10 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Ni bilo mogoče pridobiti vira sporočila.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Kopiranje ni uspelo';
 
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 24076bc706..993cb65cdb 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -287,6 +287,10 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Не вдалося отримати джерело повідомлення.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Помилка копіювання';
 
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 4084a19aba..6d62f29892 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -277,6 +277,10 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   String get errorCouldNotFetchMessageSource =>
       'Could not fetch message source.';
 
+  @override
+  String get errorCouldNotAccessUploadedFileTitle =>
+      'Could not access uploaded file';
+
   @override
   String get errorCopyingFailed => 'Copying failed';
 
diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart
index d46942f6d5..bb40fb98fa 100644
--- a/lib/model/internal_link.dart
+++ b/lib/model/internal_link.dart
@@ -128,6 +128,47 @@ class NarrowLink extends InternalLink {
   final int? nearMessageId;
 }
 
+/// A parsed link to an uploaded file in Zulip.
+///
+/// The structure mirrors the data required for [getFileTemporaryUrl]:
+///   https://zulip.com/api/get-file-temporary-url
+class UserUploadLink extends InternalLink {
+  UserUploadLink(this.realmId, this.path, {required super.realmUrl});
+
+  static UserUploadLink? _tryParse(String urlPath, Uri realmUrl) {
+    final match = _urlPathRegexp.matchAsPrefix(urlPath);
+    if (match == null) return null;
+    final realmId = int.parse(match.group(1)!, radix: 10);
+    return UserUploadLink(realmId, match.group(2)!, realmUrl: realmUrl);
+  }
+
+  static const _urlPathPrefix = '/user_uploads/';
+  static final _urlPathRegexp = RegExp(r'^/user_uploads/(\d+)/(.+)$');
+
+  final int realmId;
+
+  /// The remaining path components after the realm ID.
+  ///
+  /// This value excludes the slash that separates the realm ID from the
+  /// next component, but includes the rest of the URL path after that slash.
+  ///
+  /// This value should only be used as part of a URL, not elsewhere
+  /// in the Zulip API.  Other uses are likely to cause a new version
+  /// of the following issue:
+  ///   https://github.com/zulip/zulip-flutter/issues/1709
+  /// Concretely, this string might differ from the corresponding substring of
+  /// the original URL string (e.g., one found in the HTML of a Zulip message):
+  /// if there were non-ASCII characters in the original string,
+  /// then [Uri.parse] will have converted them to percent-encoded form.
+  /// This is fine as part of a URL, because then the HTTP client would
+  /// otherwise have had to percent-encode those characters anyway.
+  ///
+  /// This corresponds to `filename` in the arguments to [getFileTemporaryUrl];
+  /// but it's typically several path components,
+  /// not just one as that name would suggest.
+  final String path;
+}
+
 /// Try to parse the given URL as a page in this app, on `store`'s realm.
 ///
 /// `url` must already be a result from [PerAccountStore.tryResolveUrl]
@@ -148,27 +189,37 @@ class NarrowLink extends InternalLink {
 ///   #narrow/stream/1-announce/stream/1-announce (duplicated operator)
 // TODO(#1661): handle all valid narrow links, returning a search narrow
 InternalLink? parseInternalLink(Uri url, PerAccountStore store) {
-  if (!_isInternalLink(url, store.realmUrl)) return null;
-
-  final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
-  switch (category) {
-    case 'narrow':
-      if (segments.isEmpty || !segments.length.isEven) return null;
-      return _interpretNarrowSegments(segments, store);
+  if (!_sameOrigin(url, store.realmUrl)) return null;
+
+  if ((url.hasEmptyPath || url.path == '/')) {
+    if (url.hasQuery) return null;
+    if (!url.hasFragment) return null;
+    // The URL is of the form `/#…` relative to the realm URL,
+    // the shape used for representing a state within the web app.
+    final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
+    switch (category) {
+      case 'narrow':
+        if (segments.isEmpty || !segments.length.isEven) return null;
+        return _interpretNarrowSegments(segments, store);
+    }
+  } else if (url.path.startsWith(UserUploadLink._urlPathPrefix)) {
+    return UserUploadLink._tryParse(url.path, store.realmUrl);
   }
+
   return null;
 }
 
-/// Check if `url` is an internal link on the given `realmUrl`.
-bool _isInternalLink(Uri url, Uri realmUrl) {
+/// Check if `url` has the same origin as `realmUrl`.
+bool _sameOrigin(Uri url, Uri realmUrl) {
   try {
-    if (url.origin != realmUrl.origin) return false;
+    return url.origin == realmUrl.origin;
   } on StateError {
+    // The getter [Uri.origin] throws if the scheme is not "http" or "https".
+    // (Also if the URL is relative or certain kinds of malformed, but those
+    // should be impossible as `url` came from [PerAccountStore.tryResolveUrl]).
+    // In that case `url` has no "origin", and certainly not the realm's origin.
     return false;
   }
-  return (url.hasEmptyPath || url.path == '/')
-    && !url.hasQuery
-    && url.hasFragment;
 }
 
 /// Split `fragment` of arbitrary segments and handle trailing slashes
diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart
index 39232847b3..bd8274613f 100644
--- a/lib/widgets/actions.dart
+++ b/lib/widgets/actions.dart
@@ -7,9 +7,11 @@ import '../api/exception.dart';
 import '../api/model/model.dart';
 import '../api/model/narrow.dart';
 import '../api/route/messages.dart';
+import '../api/route/messages.dart' as messages_api;
 import '../api/route/channels.dart' as channels_api;
 import '../generated/l10n/zulip_localizations.dart';
 import '../model/binding.dart';
+import '../model/internal_link.dart';
 import '../model/narrow.dart';
 import '../model/realm.dart';
 import 'dialog.dart';
@@ -241,6 +243,42 @@ abstract final class ZulipAction {
     return fetchedMessage?.content;
   }
 
+  /// Fetch and parse a URL from [messages_api.getFileTemporaryUrl];
+  /// on failure, show an error dialog and return null.
+  static Future<Uri?> getFileTemporaryUrl(BuildContext context, UserUploadLink link) async {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+
+    String? resultUrl;
+    try {
+      final store = PerAccountStoreWidget.of(context);
+      resultUrl = (await messages_api.getFileTemporaryUrl(store.connection,
+        realmId: link.realmId, filename: link.path)).url;
+    } catch (e) {
+      if (!context.mounted) return null;
+      final errorMessage = switch (e) {
+        ZulipApiException() => e.message,
+        // TODO specific messages for common errors, like network errors
+        //   (support with reusable code)
+        _ => null,
+      };
+      showErrorDialog(context: context,
+        title: zulipLocalizations.errorCouldNotAccessUploadedFileTitle,
+        message: errorMessage);
+      return null;
+    }
+    if (!context.mounted) return null;
+
+    final tempUrl = PerAccountStoreWidget.of(context).tryResolveUrl(resultUrl);
+    if (tempUrl == null) {
+      showErrorDialog(context: context,
+        title: zulipLocalizations.errorCouldNotAccessUploadedFileTitle,
+        message: zulipLocalizations.errorInvalidResponse);
+      return null;
+    }
+
+    return tempUrl;
+  }
+
   static Future<void> subscribeToChannel(BuildContext context, {
     required int channelId,
   }) async {
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 2816e90233..4544fc91c4 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1436,6 +1436,12 @@ void _launchUrl(BuildContext context, String urlString) async {
           narrow: internalLink.narrow,
           initAnchorMessageId: internalLink.nearMessageId)));
 
+    case UserUploadLink():
+      final tempUrl = await ZulipAction.getFileTemporaryUrl(context, internalLink);
+      if (!context.mounted) return null;
+      if (tempUrl == null) return;
+      await PlatformActions.launchUrl(context, tempUrl);
+
     case null:
       await PlatformActions.launchUrl(context, url);
   }


Test patch:
diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart
index 74345f9811..c256286b6a 100644
--- a/test/model/internal_link_test.dart
+++ b/test/model/internal_link_test.dart
@@ -586,6 +586,34 @@ void main() {
       });
     });
   });
+
+  group('parseInternalLink uploads', () {
+    for (final (expectParse, urlPath) in [
+      (true,  '/user_uploads/123/abc.pdf'),
+      (true,  '/user_uploads/123/4/ab/5/cde'),
+      (false, '/user_uploads/123/'),
+      (false, '/user_uploads/123'),
+      (false, '/user_uploads/'),
+      (false, '/user_uploads'),
+      (false, '/user_uploads//abc.pdf'),
+      (false, '/user_uploads/-123/abc.pdf'),
+      (false, '/user_upload/123/abc.pdf'),
+    ]) {
+      test('$urlPath -> ${expectParse ? 'parse' : 'reject'}', () {
+        final store = eg.store();
+        final url = store.tryResolveUrl(urlPath)!;
+        final result = parseInternalLink(url, store);
+        if (!expectParse) {
+          check(result).isNull();
+        } else {
+          check(result).isA<UserUploadLink>();
+          result as UserUploadLink;
+          final reconstructedPath = '/user_uploads/${result.realmId}/${result.path}';
+          check(reconstructedPath).equals(urlPath);
+        }
+      });
+    }
+  });
 }
 
 extension InternalLinkChecks on Subject<InternalLink> {
diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart
index 2aa7432280..7c292aa62e 100644
--- a/test/widgets/actions_test.dart
+++ b/test/widgets/actions_test.dart
@@ -11,6 +11,7 @@ import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/model/narrow.dart';
 import 'package:zulip/api/route/messages.dart';
 import 'package:zulip/model/binding.dart';
+import 'package:zulip/model/internal_link.dart';
 import 'package:zulip/model/localizations.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/settings.dart';
@@ -244,6 +245,27 @@ void main() {
         check(await didPass).isFalse();
       });
     });
+
+    group('getFileTemporaryUrl', () {
+      testWidgets('smoke', (tester) async {
+        await prepare(tester);
+        connection.prepare(json: GetFileTemporaryUrlResult(
+          url: '/temp/s3kr1t-auth-token/paper.pdf').toJson());
+        final link = parseInternalLink(
+          store.tryResolveUrl('/user_uploads/123/ab/paper.pdf')!, store);
+
+        final future = ZulipAction.getFileTemporaryUrl(context,
+          link as UserUploadLink);
+        await tester.pump(Duration.zero);
+        check(connection.lastRequest).isA<http.Request>()
+          ..method.equals('GET')
+          ..url.path.equals('/api/v1/user_uploads/123/ab/paper.pdf')
+          ..url.query.isEmpty()
+          ..body.isEmpty();
+        check(await future).equals(
+          store.tryResolveUrl('/temp/s3kr1t-auth-token/paper.pdf')!);
+      });
+    });
   });
 
   group('PlatformActions', () {
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 28af496c59..cf3d7a4e1b 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart';
 import 'package:zulip/api/core.dart';
 import 'package:zulip/api/model/initial_snapshot.dart';
 import 'package:zulip/api/model/model.dart';
+import 'package:zulip/api/route/messages.dart';
 import 'package:zulip/model/content.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/settings.dart';
@@ -21,6 +22,7 @@ import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/store.dart';
 import 'package:zulip/widgets/text.dart';
 
+import '../api/fake_api.dart';
 import '../example_data.dart' as eg;
 import '../flutter_checks.dart';
 import '../model/binding.dart';
@@ -944,6 +946,9 @@ void main() {
   });
 
   group('LinkNode on internal links', () {
+    late PerAccountStore store;
+    late FakeApiConnection connection;
+
     Future<List<Route<dynamic>>> prepare(WidgetTester tester, String html) async {
       final pushedRoutes = <Route<dynamic>>[];
       final testNavObserver = TestNavigatorObserver()
@@ -959,12 +964,13 @@ void main() {
       assert(pushedRoutes.length == 1);
       pushedRoutes.removeLast();
 
-      final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+      store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+      connection = store.connection as FakeApiConnection;
       await store.addStream(eg.stream(name: 'stream'));
       return pushedRoutes;
     }
 
-    testWidgets('valid internal links are navigated to within app', (tester) async {
+    testWidgets('narrow links are navigated to within app', (tester) async {
       final pushedRoutes = await prepare(tester,
         '<p><a href="/#narrow/stream/1-check">stream</a></p>');
 
@@ -976,6 +982,22 @@ void main() {
 
     // TODO(#1570): test links with /near/ go to the specific message
 
+    testWidgets('uploaded-file links are opened with temporary authed URL', (tester) async {
+      final pushedRoutes = await prepare(tester,
+        '<p><a href="/user_uploads/123/ab/paper.pdf">paper.pdf</a></p>');
+
+      final tempUrlString = '/temp/s3kr1t-auth-token/paper.pdf';
+      final expectedUrl = eg.realmUrl.resolve(tempUrlString);
+
+      connection.prepare(json: GetFileTemporaryUrlResult(
+        url: tempUrlString).toJson());
+      await tapText(tester, find.text('paper.pdf'));
+      await tester.pump(Duration.zero);
+      check(testBinding.takeLaunchUrlCalls())
+        .single.equals((url: expectedUrl, mode: LaunchMode.inAppBrowserView));
+      check(pushedRoutes).isEmpty();
+    });
+
     testWidgets('invalid internal links are opened in browser', (tester) async {
       // Link is invalid due to `topic` operator missing an operand.
       final pushedRoutes = await prepare(tester,
@@ -1190,10 +1212,14 @@ void main() {
     }
 
     testWidgets('tapping on audio link opens it in browser', (tester) async {
-      final url = eg.realmUrl.resolve('/user_uploads/2/f2/a_WnijOXIeRnI6OSxo9F6gZM/crab-rave.mp3');
       await prepare(tester, ContentExample.audioInline.html);
+      final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+      final connection = store.connection as FakeApiConnection;
 
+      final url = eg.realmUrl.resolve('/temp/token/crab-rave.mp3');
+      connection.prepare(json: GetFileTemporaryUrlResult(url: url.path).toJson());
       await tapText(tester, find.text('crab-rave.mp3'));
+      await tester.pump(Duration.zero);
 
       final expectedLaunchMode = defaultTargetPlatform == TargetPlatform.iOS ?
         LaunchMode.externalApplication : LaunchMode.inAppBrowserView;