Skip to content

feat: add a toggle for copying direct file links - #133

Open
lnmunhoz wants to merge 3 commits into
KartikLabhshetwar:mainfrom
lnmunhoz:feat/direct-links
Open

feat: add a toggle for copying direct file links#133
lnmunhoz wants to merge 3 commits into
KartikLabhshetwar:mainfrom
lnmunhoz:feat/direct-links

Conversation

@lnmunhoz

@lnmunhoz lnmunhoz commented Sep 3, 2026

Copy link
Copy Markdown

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:

viewer   https://bettershot.site/s/<id>?b=<base64url origin>
object   https://share.example.com/s/<id>/screenshot.png

That's a good default and it stays the default. But it does mean every share depends on bettershot.site staying 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:

  • ![](url) in markdown, a README, or a docs site
  • <img src> / <video src> embeds
  • Slack, Discord and iMessage unfurling to an inline preview rather than a link card
  • curl, wget, or anything else that expects the bytes to be at the URL

Today 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:

Viewer link Direct link
Title, poster, download button yes no
Filename hidden behind a random id yes no, it's in the URL
Works as <img src> / in markdown no yes
Depends on bettershot.site yes no

The 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 from objectPrefix(id:) — the same helper that produces the upload key — so the link can't drift from where the file actually went.
  • The https-only origin check moved into normalizedOrigin, now shared by pageURL and directURL instead 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.
  • The filename is percent-encoded as one path segment, with / subtracted from .urlPathAllowed. sanitizedFilename already strips separators, so this is belt-and-braces against a name reshaping the key.
  • Public URL is relabelled Public 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.
  • The poster and manifest are still uploaded in direct mode. The bundle stays complete, so the viewer page for the same id keeps working and the toggle stays purely a choice about which link lands on your clipboard, not a one-way change to what got uploaded. If you'd rather direct mode skip those two objects and be leaner, that's a small change — say the word.

Validating the address

Pushed a second commit for this, because the toggle made an existing gap load-bearing.

Test Connection never touches the Public Bucket URL — it exercises the account, keys and bucket — and isConfigured only checks the field is non-empty. So a field containing nothing but https:// 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.validatePublicBaseURL now lives next to normalizedOrigin, so Settings and the uploader can't drift on what counts as usable:

Input Result
https:// not a complete address
http://cdn.example.com, cdn.example.com must start with https://
https://localhost not a complete address — a public bucket is always on a dotted domain
https://cdn.example.com?x=1 remove the query; the key is appended to this
https://cdn.example.com/files accepted — a bucket under a subpath is a normal setup

One ordering detail that's easy to get wrong: the scheme check has to run before normalizedOrigin, which strips the trailing slashes off a bare https:// 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 share keeps its original gating.

Testing

Tests/ShareLinkCheck.swift gains checkDirectURL(), covering:

  • http:// origins, an empty origin, and an empty filename all return nil
  • a pasted trailing slash still produces a valid URL
  • the result equals objectPrefix(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 diverge
  • the link never contains pageBaseURL, since routing through the viewer is the exact thing the toggle exists to avoid
  • a filename with a space comes out percent-encoded

checkPublicBaseURLValidation() 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: ShareLinkCheck passes, and make release (what CI runs) builds clean.

Screenshots

New section of the menu for toggle the direct link:
BetterShot_Annotated_8188B0

Popup after sharing with custom url:
image

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.
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

@lnmunhoz is attempting to deploy a commit to the knox projects Team on Vercel.

A member of the Team first needs to authorize it.

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")

@lnmunhoz lnmunhoz Sep 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant