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

Issue:
Uploaded file's URL gets percent-encoded, breaking permissions
If someone sends a message with an uploaded file whose filename contains non-ASCII characters, we end up percent-encoding those characters in the URL in the message's link to the file.

The resulting behavior is that the file is visible to the person who originally sent it, but not to others who received the message, because the server doesn't recognize it as the same file when assigning permissions based on the message.

The URL comes from https://zulip.com/api/upload-file (i.e. `UploadFileResult.uri` — which BTW we should rename to `url`). Currently we call `Uri.parse` on it, and then `.toString()` on the resulting `Uri` object, which has the effect of percent-encoding it. Instead, we should pass the original `uri`/`url` value through verbatim as a String.

### References
* Original report: [#issues > unable to download files contain Korean letters @ 💬](https://chat.zulip.org/#narrow/channel/9-issues/topic/unable.20to.20download.20files.20contain.20Korean.20letters/near/2215859)
* Diagnosis: [#issues > unable to download files contain Korean letters @ 💬](https://chat.zulip.org/#narrow/channel/9-issues/topic/unable.20to.20download.20files.20contain.20Korean.20letters/near/2223217), and later [#issues > unable to download files contain Korean letters @ 💬](https://chat.zulip.org/#narrow/channel/9-issues/topic/unable.20to.20download.20files.20contain.20Korean.20letters/near/2223661)


Discussion hints:
nan

Pull request: For uploads, use verbatim URL string from server
Fixes #1709.

## Commit messages

#### 314c83c09 api [nfc]: Rename field UploadFileResult.url from "uri"

Like elsewhere in our codebase, use the standard term "URL":
  https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#url-not-uri



#### c7f336c1e compose [nfc]: Take a URL string at inlineLink, rather than a Uri object

This way this function is a more faithful wrapper of constructing
the actual Markdown syntax.  The Markdown will have a URL string,
after all, so it's for the best to put this function's caller in
control of exactly what string that is.



#### 2e06a67ff compose: For uploads, use verbatim URL string from server

Fixes #1709.

Round-tripping through `Uri.parse` and `.toString()` had the effect
of percent-encoding any non-ASCII characters in the given URL string.
The server expects, reasonably enough, that the client will refer to
the upload using the same URL string the server provided at upload
time; so do that.



#### 6a35cc47f compose [nfc]: Simplify inlineLink by dropping null destination

Now that the caller is passing a string instead of a Uri object, the
caller that wants an empty string here can just pass an empty string.



Test command: flutter test test/api/route/messages_test.dart test/model/compose_test.dart test/widgets/compose_box_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart
index 05364951cd..44053bcc4a 100644
--- a/lib/api/route/messages.dart
+++ b/lib/api/route/messages.dart
@@ -315,10 +315,11 @@ Future<UploadFileResult> uploadFile(
 
 @JsonSerializable(fieldRename: FieldRename.snake)
 class UploadFileResult {
-  final String uri;
+  @JsonKey(name: 'uri')
+  final String url;
 
   UploadFileResult({
-    required this.uri,
+    required this.url,
   });
 
   factory UploadFileResult.fromJson(Map<String, dynamic> json) =>
diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart
index 0df3e678e6..306a58ca4d 100644
--- a/lib/api/route/messages.g.dart
+++ b/lib/api/route/messages.g.dart
@@ -50,10 +50,10 @@ Map<String, dynamic> _$UpdateMessageResultToJson(
 ) => <String, dynamic>{};
 
 UploadFileResult _$UploadFileResultFromJson(Map<String, dynamic> json) =>
-    UploadFileResult(uri: json['uri'] as String);
+    UploadFileResult(url: json['uri'] as String);
 
 Map<String, dynamic> _$UploadFileResultToJson(UploadFileResult instance) =>
-    <String, dynamic>{'uri': instance.uri};
+    <String, dynamic>{'uri': instance.url};
 
 UpdateMessageFlagsResult _$UpdateMessageFlagsResultFromJson(
   Map<String, dynamic> json,
diff --git a/lib/model/compose.dart b/lib/model/compose.dart
index f1145e555f..09b02cb08c 100644
--- a/lib/model/compose.dart
+++ b/lib/model/compose.dart
@@ -188,8 +188,8 @@ String wildcardMention(WildcardMentionOption wildcardOption, {
 /// result may be surprising.
 ///
 /// The part between "(" and ")" is just a "link destination" (no "link title").
-/// That destination is simply the stringified [destination], if provided.
-/// If that has parentheses in it, the result may be surprising.
+/// That destination is the string [destination].
+/// If [destination] has parentheses in it, the result may be surprising.
 // TODO: Try harder to guarantee output that creates an inline link,
 //   and in particular, the intended one. We could help with this by escaping
 //   square brackets, perhaps with HTML character references:
@@ -199,8 +199,8 @@ String wildcardMention(WildcardMentionOption wildcardOption, {
 //   > Backtick code spans, autolinks, and raw HTML tags bind more tightly
 //   > than the brackets in link text. Thus, for example, [foo`]` could not be
 //   > a link text, since the second ] is part of a code span.
-String inlineLink(String visibleText, Uri? destination) {
-  return '[$visibleText](${destination?.toString() ?? ''})';
+String inlineLink(String visibleText, String destination) {
+  return '[$visibleText]($destination)';
 }
 
 /// What we show while fetching the target message's raw Markdown.
@@ -213,7 +213,7 @@ String quoteAndReplyPlaceholder(
     SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
     nearMessageId: message.id);
   return '${userMentionFromMessage(message, silent: true, users: store)} '
-    '${inlineLink('said', url)}: ' // TODO(#1285)
+    '${inlineLink('said', url.toString())}: ' // TODO(#1285)
     '*${zulipLocalizations.composeBoxLoadingMessage(message.id)}*\n';
 }
 
@@ -237,6 +237,6 @@ String quoteAndReply(PerAccountStore store, {
   // and the extra noise won't much matter with the already probably-long
   // message link in there too.
   return '${userMentionFromMessage(message, silent: true, users: store)} '
-    '${inlineLink('said', url)}:\n' // TODO(#1285)
+    '${inlineLink('said', url.toString())}:\n' // TODO(#1285)
     '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}';
 }
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 6514631955..99eeef3b0d 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -350,7 +350,7 @@ class ComposeContentController extends ComposeController<ContentValidationError>
     final tag = _nextUploadTag;
     _nextUploadTag += 1;
     final linkText = zulipLocalizations.composeBoxUploadingFilename(filename);
-    final placeholder = inlineLink(linkText, null);
+    final placeholder = inlineLink(linkText, '');
     _uploads[tag] = (filename: filename, placeholder: placeholder);
     notifyListeners(); // _uploads change could affect validationErrors
     value = value.replaced(insertionIndex(), '$placeholder\n\n');
@@ -359,9 +359,9 @@ class ComposeContentController extends ComposeController<ContentValidationError>
 
   /// Tells the controller that a file upload has ended, with success or error.
   ///
-  /// To indicate success, pass the URL to be used for the Markdown link.
+  /// To indicate success, pass the URL string to be used for the Markdown link.
   /// If `url` is null, failure is assumed.
-  void registerUploadEnd(int tag, Uri? url) {
+  void registerUploadEnd(int tag, String? url) {
     final val = _uploads[tag];
     assert(val != null, 'registerUploadEnd called twice for same tag');
     final (:filename, :placeholder) = val!;
@@ -971,7 +971,7 @@ Future<void> _uploadFiles({
 
   for (final (tag, file) in uploadsInProgress) {
     final _File(:content, :length, :filename, :mimeType) = file;
-    Uri? url;
+    String? url;
     try {
       final result = await uploadFile(store.connection,
         content: content,
@@ -979,7 +979,7 @@ Future<void> _uploadFiles({
         filename: filename,
         contentType: mimeType,
       );
-      url = Uri.parse(result.uri);
+      url = result.url;
     } catch (e) {
       if (!context.mounted) return;
       // TODO(#741): Specifically handle `413 Payload Too Large`


Test patch:
diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart
index f00bf4428f..a862b512b6 100644
--- a/test/api/route/messages_test.dart
+++ b/test/api/route/messages_test.dart
@@ -567,7 +567,7 @@ void main() {
       required String? contentType,
     }) async {
       connection.prepare(json:
-        UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/$filename').toJson());
+        UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/$filename').toJson());
       await uploadFile(connection,
         content: Stream.fromIterable(content),
         length: length,
diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart
index 2031b69d28..d7479f8ec5 100644
--- a/test/model/compose_test.dart
+++ b/test/model/compose_test.dart
@@ -319,9 +319,9 @@ hello
   });
 
   test('inlineLink', () {
-    check(inlineLink('CZO', Uri.parse('https://chat.zulip.org/'))).equals('[CZO](https://chat.zulip.org/)');
-    check(inlineLink('Uploading file.txt…', null)).equals('[Uploading file.txt…]()');
-    check(inlineLink('IMG_2488.png', Uri.parse('/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png')))
+    check(inlineLink('CZO', 'https://chat.zulip.org/')).equals('[CZO](https://chat.zulip.org/)');
+    check(inlineLink('Uploading file.txt…', '')).equals('[Uploading file.txt…]()');
+    check(inlineLink('IMG_2488.png', '/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png'))
       .equals('[IMG_2488.png](/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png)');
   });
 
diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index f39e74c138..d79acce925 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -1070,7 +1070,7 @@ void main() {
           size: 12345,
         )]);
         connection.prepare(delay: const Duration(seconds: 1), json:
-          UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());
+          UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());
 
         await tester.tap(find.byIcon(ZulipIcons.image));
         await tester.pump();
@@ -1129,7 +1129,7 @@ void main() {
           path: '/private/var/mobile/Containers/Data/Application/foo/tmp/image.jpg',
         );
         connection.prepare(delay: const Duration(seconds: 1), json:
-          UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());
+          UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());
 
         await tester.tap(find.byIcon(ZulipIcons.camera));
         await tester.pump();
@@ -1168,6 +1168,34 @@ void main() {
     // target platform the test is simulating.
     // TODO(upstream): unskip after fix to https://github.com/flutter/flutter/issues/161073
     skip: Platform.isWindows);
+
+    testWidgets('use verbatim URL string from server, not re-encoded', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1709
+      TypingNotifier.debugEnable = false;
+      addTearDown(TypingNotifier.debugReset);
+
+      final channel = eg.stream();
+      final narrow = eg.topicNarrow(channel.streamId, 'a topic');
+      await prepareComposeBox(tester, narrow: narrow, streams: [channel]);
+
+      testBinding.pickFilesResult = FilePickerResult([PlatformFile(
+        readStream: Stream.fromIterable(['asdf'.codeUnits]),
+        path: '/some/path/한국어 파일.txt',
+        name: '한국어 파일.txt',
+        size: 4,
+      )]);
+      connection.prepare(json: UploadFileResult(url:
+        '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/한국어 파일.txt').toJson());
+      await tester.tap(find.byIcon(ZulipIcons.image));
+      await tester.pump();
+      check(controller!.content.text)
+        .equals('[Uploading 한국어 파일.txt…]()\n\n');
+
+      await tester.pump(Duration.zero);
+      check(controller!.content.text)
+        .equals('[한국어 파일.txt]('
+          '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/한국어 파일.txt)\n\n');
+    });
   });
 
   group('error banner', () {
@@ -1833,7 +1861,7 @@ void main() {
         testBinding.pickFilesResult = FilePickerResult([
           PlatformFile(name: 'file.jpg', size: 1000, readStream: Stream.fromIterable(['asdf'.codeUnits]))]);
         connection.prepare(json:
-          UploadFileResult(uri: '/path/file.jpg').toJson());
+          UploadFileResult(url: '/path/file.jpg').toJson());
         await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false);
         await tester.pump(Duration.zero);
         checkNoErrorDialog(tester);