feat: add a toggle for copying direct file links - #133
Open
lnmunhoz wants to merge 3 commits into
Open
Conversation
Every share link points at a viewer page on bettershot.site, which puts a third party in the path of a file that is already sitting in the user's own bucket. The new Sharing setting copies a link straight to that object instead. Off by default, so existing installs keep getting viewer links. The https-only origin check moves into normalizedOrigin, shared by pageURL and directURL. The filename is percent-encoded as a single path segment, so a name containing a separator cannot reshape the key.
|
@lnmunhoz is attempting to deploy a commit to the knox projects Team on Vercel. A member of the Team first needs to authorize it. |
lnmunhoz
commented
Sep 3, 2026
| secureField("Secret Access Key", text: $secretAccessKey, prompt: "Shown once when the token is created") | ||
| credentialField("Bucket", text: $bucket, prompt: "my-bucket") | ||
| credentialField("Public URL", text: $publicBaseURL, prompt: "https://share.example.com") | ||
| credentialField("Public Bucket URL", text: $publicBaseURL, prompt: "https://share.example.com") |
Author
There was a problem hiding this comment.
Changed wording here just to be more clear. Public URL was confusing.
Test Connection never touches the Public Bucket URL - it exercises the account, keys and bucket - so a field holding nothing but "https://" still reported Connected, and the first sign of trouble was a share failing to upload. validatePublicBaseURL sits next to normalizedOrigin so Settings and the uploader cannot drift on what counts as usable. The scheme is checked before normalizedOrigin, which strips the trailing slashes off a bare "https://" and would otherwise blame the scheme for a missing domain. Direct links depend on that address and nothing else, so the switch turns itself off when the address stops working, on edit and on open. It does that silently: the switch moving is the feedback, and the message is reserved for a tap, where the user has actually asked for something. The switch is no longer gated on isConfigured either - an empty URL already fails that, and a dead switch cannot explain itself.
| // Checked before normalizedOrigin, which strips the trailing slashes off a bare | ||
| // "https://" and would then blame the scheme for a missing domain. | ||
| guard trimmed.lowercased().hasPrefix("https://") else { return .notHTTPS } | ||
| guard let components = URLComponents(string: trimmed) else { return .invalidHost } |
Contributor
There was a problem hiding this comment.
URLs with embedded credentials like https://user:pass@host pass this validation, and normalizedOrigin carries them through verbatim — so those credentials get baked into every share link that gets copied. Worth rejecting userinfo explicitly here (would be good to pin it in checkPublicBaseURLValidation too).
Suggested change
| guard let components = URLComponents(string: trimmed) else { return .invalidHost } | |
| guard components.user == nil, components.password == nil else { return .invalidHost } | |
| guard let host = components.host, isHostname(host) else { return .invalidHost } |
The two new commits left 34 comment lines across files that had 2, 0 and 0. CONTRIBUTING asks for none unless something is non-obvious, and the surrounding code keeps to one-line /// on declarations. Down to 13, all one-liners. The tests and R2Uploader are back to the comment count they had on main; what survives in the settings tab is the three things the code cannot say itself - the guard that filters onChange echoes, the store-before-state ordering it depends on, and why the switch is not gated on isConfigured.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
BetterShot uploads to the user's own Cloudflare R2 bucket, but the link it copies doesn't point there. It points at a viewer page on
bettershot.site, which reads the manifest and renders the file:That's a good default and it stays the default. But it does mean every share depends on
bettershot.sitestaying up, and it routes a file that already lives on the user's own domain through a third party. For someone who went to the trouble of setting up R2 specifically to own their sharing, the link is the one piece they can't own.There's a practical side too. A viewer link is an HTML page, so it doesn't work anywhere a raw file URL is expected:
in markdown, a README, or a docs site<img src>/<video src>embedscurl,wget, or anything else that expects the bytes to be at the URLToday the only workaround is to open the viewer, right-click the media and copy that address by hand, on every single share.
What
A Copy direct file links toggle in Settings › Sharing. Turned on, the copied link is the object in your own bucket instead of the viewer page.
Off by default, so existing installs keep getting viewer links and nothing changes for anyone who doesn't go looking for it.
The trade-off, stated plainly
The viewer page is genuinely better for most people, which is why it remains the default:
<img src>/ in markdownbettershot.siteThe settings footer spells this out at the point of decision, so it reads as an informed choice rather than a mystery switch.
Implementation notes
ShareBundle.directURL(id:filename:publicBaseURL:)builds the URL fromobjectPrefix(id:)— the same helper that produces the upload key — so the link can't drift from where the file actually went.normalizedOrigin, now shared bypageURLanddirectURLinstead of living only in the former. Same behaviour as before: a pasted trailing slash is tolerated,http://is rejected. A direct link over http would be no better than a viewer over http, so the rule holds for both./subtracted from.urlPathAllowed.sanitizedFilenamealready strips separators, so this is belt-and-braces against a name reshaping the key.Public URLis relabelledPublic Bucket URL. In viewer mode that field is almost decorative — it gets base64'd into a query parameter. In direct mode it is the origin the file is served from. The old label undersold what it does, and the footer now says outright that this is where your files are stored and served.Validating the address
Pushed a second commit for this, because the toggle made an existing gap load-bearing.
Test Connectionnever touches the Public Bucket URL — it exercises the account, keys and bucket — andisConfiguredonly checks the field is non-empty. So a field containing nothing buthttps://reported Connected, and the first sign of trouble was a share failing to upload. That was survivable when the field was only base64'd into a viewer link. It isn't once a direct link is built straight from it.ShareBundle.validatePublicBaseURLnow lives next tonormalizedOrigin, so Settings and the uploader can't drift on what counts as usable:https://http://cdn.example.com,cdn.example.comhttps://https://localhosthttps://cdn.example.com?x=1https://cdn.example.com/filesOne ordering detail that's easy to get wrong: the scheme check has to run before
normalizedOrigin, which strips the trailing slashes off a barehttps://and would then blame the scheme for what is really a missing domain.Behaviour of the switch
Direct links depend on that address and nothing else, so the switch turns itself off when the address stops working — both when the field is edited and when Settings is opened against a setting stored in an earlier session. It does that silently: the switch moving is the feedback, and the message is reserved for a tap, where the user actually asked for something.
The switch is also no longer gated on
isConfigured. An empty URL already fails that check, so the switch was greyed out in exactly the case where it most needed to explain itself.Upload when I sharekeeps its original gating.Testing
Tests/ShareLinkCheck.swiftgainscheckDirectURL(), covering:http://origins, an empty origin, and an empty filename all returnnilobjectPrefix(id:) + filename— asserted against the real key helper rather than a hand-written string, so the test fails if the upload key and the link ever divergepageBaseURL, since routing through the viewer is the exact thing the toggle exists to avoidcheckPublicBaseURLValidation()covers the table above, and asserts the converse too: every address the validator accepts must actually build both a direct link and a viewer link, so it can never green-light an origin the builders reject.Verified locally:
ShareLinkCheckpasses, andmake release(what CI runs) builds clean.Screenshots
New section of the menu for toggle the direct link:

Popup after sharing with custom url:
