-
-
Notifications
You must be signed in to change notification settings - Fork 669
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
View uploaded files without needing to be logged in to the realm in the browser. #4089
Conversation
@chrisbobbe @gnprice this PR is ready for a review. |
Great, glad to have this so quickly! Looking now. |
@chrisbobbe I force pushed some non-trivial changes after you commented. |
Yep, thanks! I saw the update and I'm looking at the series up to 9c0b4bc now. |
1090b87
to
33c17e8
Compare
@chrisbobbe there were some errors in the code. I've pushed some changes. Apologies for the inconvenience. I've thoroughly tested now. |
No problem! 🙂 I actually hadn't gotten very deep in my review when I took a break to eat lunch; back now. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, @agrawal-d! To me, this looks like it's on the right track.
There were quite a few details involved in the server-side implementation discussion (how to handle local vs. s3 backends, etc.), so I imagined this taking longer to figure out. But I think something like this is where we ended up, following @gnprice's comment at #3303 (comment).
A few comments below; I'm sure @gnprice will have more to add.
At time of writing, this handles all embedded data in all routes in all API calls used by the mobile app. However, that won't be true forever. In particular, PR zulip#4089 introduces code for a new endpoint `getTemporaryFileUri` which contains an arbitrary filename. This endpoint will have to be handled differently -- either by calling `apiCall` directly, or by adding a bit more plumbing through `apiGet` and friends.
At time of writing, this handles all embedded data in all routes in all API calls used by the mobile app. However, that won't be true forever. In particular, PR zulip#4089 introduces code for a new endpoint `getTemporaryFileUri` which contains an arbitrary filename. This endpoint will have to be handled differently -- either by calling `apiCall` directly, or by adding a bit more plumbing through `apiGet` and friends.
@chrisbobbe Thanks for the review!
You are right! Even on Android, if the browser can't handle a file, but some other app can, a dialog is shown to choose the app to open it with, apart from the browser. For example -
So, I think it's best to let the browser handle all files, for an overall better experience. I've changed the PR to reflect this. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! One quick comment below, but I'll leave Chris to continue to do the full review.
So, I think it's best to let the browser handle all files, for an overall better experience. I've changed the PR to reflect this.
Before seeing this I replied on a related inline comment thread:
#4089 (comment)
but in short that SGTM.
src/message/messagesActions.js
Outdated
// We don't want to generate temporary URLs to external websites. | ||
if (!url.includes(auth.realm)) { | ||
openLink(url); | ||
return; | ||
} | ||
|
||
let tempUrl: string; | ||
try { | ||
tempUrl = getFullUrl((await getTemporaryFileUrl(auth, href)).url, auth.realm); | ||
} catch (err) { | ||
// If we failed to generate the temporary URL, we assume it's because the | ||
// server doesn't suport it yet, and fallback to use the regular file | ||
// URL. | ||
openLink(url); | ||
return; | ||
} | ||
openLink(tempUrl); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic is kind of tricky, especially around the try/catch.
A couple of things that I think would help:
-
Every control-flow path out of this code ends up calling
openLink
at the end. Make that explicit by having just oneopenLink
call. Have a helper function that returns the URL we'll pass toopenLink
. That function will contain some control flow.This also makes an opportunity to give that helper function a meaningful name, which I think will also add clarity.
-
Have the
try
block be as small as possible. As is, we'd end up in thecatch
if there's somehow an exception thrown fromgetFullUrl
. That shouldn't happen, but ruling that out requires going and reading more code. Instead that call can be after the whole try/catch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, @agrawal-d! A few comments, and I agree with Greg's, as well. This revision feels more streamlined, and closer to a pure fix for the annoying need-to-login issue; lots of people will be quite happy to see it working soon. 🙂
|
||
/** | ||
* This endpoint, introduced in zulip/zulip@9e72fbe3f, returns a partial URL | ||
* string without the realm domain included in it. This URL allows temporarily |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good to have these details in a jsdoc!
I'm struggling to find an example in our API bindings, but Greg guided me toward some conventions we use, when I was writing src/utils/zuilpVersion.js
(you could take a look at that file):
- Start with a terse single-line summary of what the thing is/does.
- Then some summary, which gives relevant background, or places you might want to use the thing, or reasons to use it instead of something else, etc.
Good to include the details in * @param filePath
, I think. Not sure what to do about auth
; it's boring, since all the other bindings have it too. Maybe just an empty @param auth
, with no explanation? Order matters, so I think there's an argument for putting something minimal before @param filePath
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure (and it doesn't seem to be documented in the zulip.yaml file you link to) whether the backend implementation ended up giving us a short-lived URL that was
- just completely random, or
- based on a combination of the user (even their private API key?) and the requested document, with, I guess,
2a. some randomness on top of that, or
2b. completely determined by this info combined with a timestamp?
Possibly, the answer to this won't ever be important for the mobile app to be aware of, from a security perspective or otherwise, or these are considered implementation details by the server, so could change at any time. Assuming one of these is true, it seems appropriate for us to be intentionally vague about those details. The important piece is that the URL won't be valid after, I think, 60 seconds or so (edit: and a few other things, noted in my next comment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there's even more detail about why we're using this endpoint, which would be good to include. See Greg's points here:
The goal basically is to get
- (a) a plain old URL that can be passed to a browser (not a URL plus some cookies or other headers that need to be set)
- (b) that gets the user the file immediately, without requiring a fresh login
- (c) but doesn't include a secret with much wider powers (i.e. their API key), which would risk a nasty surprise if they innocently show someone else the file by sending them the link.
I think (a) is explained by "without sending auth headers", but let's get (b) and (c) in there too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure (and it doesn't seem to be documented in the zulip.yaml file you link to) whether the backend implementation ended up giving us a short-lived URL that was
- just completely random, or
- based on a combination of the user (even their private API key?) and the requested document, with, I guess,
2a. some randomness on top of that, or
2b. completely determined by this info combined with a timestamp?
I believe the implementation ended up using Django's django.core.signing
, either its dumps
function or the underlying TimestampSigner
. There's docs here:
https://docs.djangoproject.com/en/3.0/topics/signing/
and the cryptographic concept it implements is an "HMAC":
https://en.wikipedia.org/wiki/HMAC
Basically we take the IDs of the user and the requested document, make a sentence saying "As of TIME, user USER can access file FILE", and then compute a signature. The URL contains that sentence and signature.
The signature is done with a secret key belonging to the server, and can be verified with the same secret key by just re-computing what the signature should be.
The HMAC is a common technique for webapps to hand the client some piece of data, especially this kind of access token, in what amounts to a transparent but tamper-proof envelope so the client can hand it back to the server later and the server can trust the contents. Just as it did for us here, this technique saves the server from having to store the tokens itself.
Assuming one of these is true, it seems appropriate for us to be intentionally vague about those details.
Yeah, I think those are implementation details the client needn't care about, and the other points you mentioned are more relevant.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
|}; | ||
|
||
/** | ||
* This endpoint, introduced in zulip/zulip@9e72fbe3f, returns a partial URL |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
introduced in zulip/zulip@9e72fbe3f
Eventually, we'll move to including a value of zulip_feature_level
(doc) here, and we'll even be able to use that for feature detection in using this feature, as Greg notes at #3303 (comment), following #4049, but I think this is fine for now; a commit ID is good because it's as specific as it's possible to be.
At time of writing, this handles all embedded data in all routes in all API calls used by the mobile app. However, that won't be true forever. In particular, PR zulip#4089 introduces code for a new endpoint `getTemporaryFileUri` which contains an arbitrary filename. This endpoint will have to be handled differently -- either by calling `apiCall` directly, or by adding a bit more plumbing through `apiGet` and friends.
At time of writing, this handles all embedded data in all routes in all API calls used by the mobile app. However, that won't be true forever. In particular, PR zulip#4089 introduces code for a new endpoint `getTemporaryFileUri` which contains an arbitrary filename. This endpoint will have to be handled differently -- either by calling `apiCall` directly, or by adding a bit more plumbing through `apiGet` and friends.
At time of writing, this handles all embedded data in all routes in all API calls used by the mobile app. However, that won't be true forever. In particular, PR zulip#4089 introduces code for a new endpoint `getTemporaryFileUri` which contains an arbitrary filename. This endpoint will have to be handled differently -- either by calling `apiCall` directly, or by adding a bit more plumbing through `apiGet` and friends.
At time of writing, this handles all embedded data in all routes in all API calls used by the mobile app. However, that won't be true forever. In particular, PR zulip#4089 introduces code for a new endpoint `getTemporaryFileUri` which contains an arbitrary filename. This endpoint will have to be handled differently -- either by calling `apiCall` directly, or by adding a bit more plumbing through `apiGet` and friends.
dd72ae2
to
5ae7d53
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @agrawal-d ! Code LGTM now, with just a couple of small comments below.
I still need to try it out end-to-end -- or @chrisbobbe, if you've done so then also feel free to merge.
991841d
to
8c311d3
Compare
OK, tried this end-to-end, in an emulator running Android 8.
|
(Oh, and I was curious where that error message comes from, with its garbled syntax:
Found it with |
Again in an emulator running Android 8, testing this end to end:
|
OK, and this time on iOS, in a simulated iPhone 8 running iOS 13.3.
|
Same iOS environment, now testing #2115.
|
This JSDoc adds useful information about what it does, and it's limitations.
The endpoint GET `/user_uploads/{realm_id_str}/{filename}` allows for the generation of a temporary file URL that does not require authentication. This endpoint was added in zulip/zulip@9e72fbe3f. The goal basically is to get: (a) a plain old URL that can be passed to a browser (not a URL plus some cookies or other headers that need to be set). (b) that gets the user the file immediately, without requiring a fresh login. (c) but doesn't include a secret with much wider powers (i.e. their API key), which would risk a nasty surprise if they innocently show someone else the file by sending them the link. Commit-message-suggested-by: Greg Price <greg@zulipchat.com>
Using the new endpoint `/user_uploads/{realm_id_str}/{filename}` it generates a temporary URL that does not require auth to access. This allows the user to open the file in the browser without being logged in to the realm, or to directly download it without redirects to S3 or needing to send auth headers. If generation of the url fails, it returns `null`.
Aha, and I may have spotted what's going wrong in the #3033 case on iOS! From that error page, I hit the "share" icon and chose "Copy", to get the URL onto the clipboard. Then went and pasted it elsewhere (the compose box, as the handiest place.) Here it is: I went and pulled up the same upload in the webapp, to compare. (It works fine there.) Here's the URL I find in the location bar there: I think the problem is that So it seems like we're double-encoding the URL, and as a result the decoding of it has a signature ending in Anyway, about to merge, and then I'll file issues for the various followups. |
Using the new helper tryGetFileDownloadURL, we now try to generate and use temporary authless URLs for realm uploaded files. We open files using the temporary URL in the browser, as opposed to downloading them directly, because 1. The browser can usually open several file types instantly and 2. If the browser can not handle displaying the file, a system dialog shows up, both on Android and iOS, showing platform specific ways to handle the file, such as opening in Google Drive, Saving to iCloud and other options based on installed apps, which is convenient for the user. [greg: On iOS this doesn't actually work yet, but the bug it runs into is unrelated to zulip#3303.] Fixes: zulip#3303
Using the helper `tryGetFileDownloadUrl`, we try to generate and use temporary authless URLs to download files from the lighbox. This fixes an issue where redirects to S3 caused RNFetchBlob to fail downloading the file. If we fail to download the file, which happens if we failed to generate the temporary URL because the server does not support it, we open it in the browser instead, from where the user can easily save it. Also makes changes in other places required due to this change. Fixes: zulip#2115
I think the short answer to this question may be "no", sadly -- the API we're using, and similarly its latest version, doesn't have a way to request the permission before committing to a specific URL to try to save. That I think is in turn probably ultimately a consequence of Apple's (reasonable!) desire to push apps to show permissions prompts only right at the moment where there's a clear thing the app wants to use the permission to do. Ah well. These temporary URLs last 60 seconds, and it probably won't be common for a user to hesitate at a permission prompt for that long, or close to it. And if they do, it's a one-time occurrence anyway. |
Closes #3303.