From a919d0c9982adf2584f0d47e68999e92256341a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 13:18:46 +0000 Subject: [PATCH 1/2] Default numeric hosts to http, named hosts to https; add a README A target with no scheme now gets one chosen by what the host looks like: a numeric host is a device on the local network, which rarely has a certificate, while a named host should be https. example.com -> https://example.com example.com:8080 -> https://example.com:8080 192.168.1.50:8123 -> http://192.168.1.50:8123 Applied in the three places that defaulted a scheme, which previously disagreed: index.html used http, forward.js and metadata.js used https. Two latent bugs fall out of the same change. index.html treated any colon as a scheme, so a host and port such as 192.168.1.50:8123 was left without one entirely. metadata.js tested startsWith("http"), so a hostname beginning with those letters, like httpbin.org, was treated as already carrying a scheme. Both now share hasScheme(), which distinguishes "example.com:8080" from "tel:5551234" by whether the part before the colon is dotted and the part after is purely a number. The README covers the path grammar, the routes, the fallback probing, the Cast setup including the published app id, and the routing-order constraints in netlify.toml that are easy to break. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014nP4CW7aH6ei3eHbBb1oYd --- README.md | 166 ++++++++++++++++++++++++++++- docs/forward.js | 17 ++- docs/index.html | 17 ++- netlify/edge-functions/metadata.js | 17 ++- 4 files changed, 213 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b52ed62..bed8d0e 100644 --- a/README.md +++ b/README.md @@ -1 +1,165 @@ -# redirect.app \ No newline at end of file +# redirect.app + +A redirector where the URL is the data. + +One static endpoint stands in for unlimited specific things, because everything +it needs is encoded in the path. There is no database and no stored state. That +one idea shows up three times: as link previews you author in a URL, as a +deep-link redirector with a fallback, and as a Cast receiver that will display +any page you hand it. + +Hosted on Netlify at . The apex 301s to `www`. + +## Routes + +| Route | What it does | +|---|---| +| `/#` | Forward to a URL. `?FALLBACK=` adds a second chance — see below. | +| `//d/<desc>/u/<url>/` | A link preview authored entirely in the path. Bots get Open Graph tags, people get forwarded. | +| `/m/<Title>/…` | The same, but forces the metadata response for any user agent. Useful for checking your work. | +| `/edit/` | Visual builder for those preview URLs, with per-platform previews. | +| `/view/u/<url>/` | Forward to a target, or wrap it in a frame with `m/f`. | +| `/cast#<url>` | Cast sender. Pushes one URL to a Chromecast. | +| `/cast/receiver/` | The registered Cast receiver. Not meant to be opened directly. | +| `/bot/link` | Discord slash-command endpoint for `/embed`. | + +## Path grammar + +Paths are `/key/value/` pairs. The first segment is the title, so +`/Hello/d/World/` means title `Hello`, description `World`. + +| Key | Meaning | +|---|---| +| `t` | Title (implicit in the first segment) | +| `d` | Description | +| `s` | Site name | +| `u` | Target URL | +| `i` | Image (`og:image`), with `iw` / `ih` for dimensions | +| `v` | Video (`og:video`), with `vw` / `vh` | +| `f` | Favicon: a URL, or a single emoji | +| `c` | Theme colour, hex without the `#` | +| `y` | `og:type` | +| `m` | `/view/` only — `t` forwards (default), `f` wraps in a frame | + +Three encodings keep these readable: + +- **Dashes are spaces.** `-` is a space, `--` a hyphen, `---` a spaced hyphen. + So `Kitchen---Home-Assistant` reads back as `Kitchen - Home Assistant`. +- **`:host` means `https://host`.** `u/:example.com/` saves the boilerplate. +- **Bare tokens may be base64.** A token with no dot or slash is treated as + base64, which is how long image URLs stay manageable. + +An emoji in `f` becomes a favicon via Google's Noto emoji PNGs, so +`f/%F0%9F%8F%A0/` gives you a house. + +### Scheme defaulting + +A target with no scheme gets one, chosen by what the host looks like: + +| Input | Result | +|---|---| +| `example.com` | `https://example.com` | +| `example.com:8080` | `https://example.com:8080` | +| `192.168.1.50:8123` | `http://192.168.1.50:8123` | + +Numeric hosts are devices on the local network, which rarely have +certificates; named hosts should be https. A host and port is not mistaken for +a scheme, and `tel:5551234` still is one. + +## Fallbacks + +`/#<primary>?FALLBACK=<fallback>` tries the primary and falls back if it looks +unreachable. Custom schemes are probed with a hidden iframe and a timing +heuristic; http(s) targets are probed by loading their favicon. The fallback +can be another URL, or a message to show before continuing to the primary. + +This is what makes a custom app scheme safe to put in a link that strangers +will open on devices where the app isn't installed. + +## Casting + +Registered Cast receiver, published and usable by anyone: + +``` +App ID CCAB7FD4 +Receiver https://www.redirect.app/cast/receiver/ +Sender https://www.redirect.app/cast +``` + +Open the sender, pick a device, send a URL. `/cast#<url>` prefills the field, +so one bookmark per dashboard works. + +**Forwarding is the default.** The receiver navigates the whole page to your +target rather than framing it. That reaches sites which refuse framing, and it +reaches plain-http dashboards on your LAN, because a top-level navigation is +not mixed content. It also ends the Cast session — the receiver is gone, so to +change what's on screen you cast again. Tick "wrap in a frame" to keep +redirect.app on top instead, where the target permits it. + +### Namespaces + +``` +urn:x-cast:app.redirect {"url": "…", "mode": "t" | "f"} +urn:x-cast:es.offd.dashcast {"url": "…", "force": true | false} +``` + +The second is [DashCast](https://github.com/stestagg/dashcast)'s own namespace +and message shape, so `catt` and the Home Assistant DashCast component work +against this app ID unchanged. DashCast frames unless told to `force`; this one +forwards unless told to wrap. Each namespace keeps its own default. + +## Layout + +``` +docs/ published by Netlify + index.html hash redirector with fallback probing + edit.html link preview builder + view.html forward or wrap a target + cast.html Cast sender + cast-receiver.html Cast receiver (this URL is registered with Google) + forward.js shared URL grammar for view + receiver +netlify/ + edge-functions/ + metadata.js renders Open Graph tags from the path + functions/ + linkbot.js Discord /embed slash command +bookmarklet/ builds a preview URL from the page you're on +netlify.toml routing — order matters, see below +``` + +Routing rules in `netlify.toml` must stay above the `/*` catch-all, and +`/cast/receiver` must stay above `/cast/*`. Netlify serves real files before +applying rewrites, which is why `/forward.js` resolves. + +The metadata edge function matches `/*/*` and skips `/view/` and `/cast/` +explicitly — without that it would treat them as link previews and redirect +bots to their targets. + +## Development + +Node 22, pinned in `.nvmrc`. + +```sh +npm install +npx netlify dev --dir docs +``` + +Then <http://localhost:8888>. Edge functions need network access to fetch their +Deno bootstrap; if that fails, static routing and the redirect rules still work, +but `/…/u/…/` won't render metadata locally. + +To check a preview URL against the real thing, deploy and fetch it as a bot: + +```sh +curl -A "Twitterbot/1.0" "https://www.redirect.app/Some-Title/d/A-description/u/:example.com/" +``` + +## Working on the Cast receiver + +The receiver URL is registered with Google and cannot move without updating the +[Cast Developer Console](https://cast.google.com/publish). If you change where +`cast-receiver.html` is served from, change the registration to match. + +Receiver changes are live as soon as Netlify deploys — the device fetches the +page fresh on each launch, and nothing about the page is cached in the +registration. Only the URL itself is. diff --git a/docs/forward.js b/docs/forward.js index 9f27b32..5149a78 100644 --- a/docs/forward.js +++ b/docs/forward.js @@ -29,12 +29,27 @@ function decodeURL(s) { try { return atob(s.replace(/=/g, "")); } catch (e) { return s; } } +function hasScheme(u) { + var m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(u); + if (!m) return false; + // "example.com:8080" is a host and port, not a scheme. "tel:5551234" is a + // scheme, so only treat it as a port when the part before the colon is dotted. + if (m[1].indexOf(".") >= 0 && /^\d+([/?#]|$)/.test(m[2])) return false; + return true; +} + +function defaultScheme(u) { + var host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0]; + // A numeric host is a device on the network, which rarely has a certificate. + return (/^[0-9.]+$/.test(host) ? "http://" : "https://") + u; +} + function normalizeTarget(v) { if (!v) return ""; v = String(v).trim(); if (v.charAt(0) === ":") return "https://" + v.substring(1); v = decodeURL(v); - if (!/^[a-z][a-z0-9+.-]*:/i.test(v)) v = "https://" + v; + if (!hasScheme(v)) v = defaultScheme(v); return v; } diff --git a/docs/index.html b/docs/index.html index 499e468..2459e72 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,6 +7,21 @@ var REDIRECT_URL = 'REDIRECT_URL' var storedValue = (document.cookie.split(REDIRECT_URL + '=')[1]||'').split(';').shift(); +function hasScheme(u) { + var m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(u); + if (!m) return false; + // "example.com:8080" is a host and port, not a scheme. "tel:5551234" is a + // scheme, so only treat it as a port when the part before the colon is dotted. + if (m[1].indexOf(".") >= 0 && /^\d+([/?#]|$)/.test(m[2])) return false; + return true; +} + +function defaultScheme(u) { + var host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0]; + // A numeric host is a device on the network, which rarely has a certificate. + return (/^[0-9.]+$/.test(host) ? "http://" : "https://") + u; +} + function askForDestination(url) { url = prompt([ "This app opens other sites and URLs", @@ -15,7 +30,7 @@ url || "https://"); if (url != null) { - if (url.indexOf(":") < 0) url = "http://" + url; + if (!hasScheme(url)) url = defaultScheme(url); var cookie = (REDIRECT_URL + '=' + url + '; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/') document.cookie = cookie; navigateTo(url, 3000, true); diff --git a/netlify/edge-functions/metadata.js b/netlify/edge-functions/metadata.js index e212821..d40591a 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -14,6 +14,21 @@ function decodeURL(s) { } } +function hasScheme(u) { + let m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(u); + if (!m) return false; + // "example.com:8080" is a host and port, not a scheme. "tel:5551234" is a + // scheme, so only treat it as a port when the part before the colon is dotted. + if (m[1].indexOf(".") >= 0 && /^\d+([/?#]|$)/.test(m[2])) return false; + return true; +} + +function defaultScheme(u) { + let host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0]; + // A numeric host is a device on the network, which rarely has a certificate. + return (/^[0-9.]+$/.test(host) ? "http://" : "https://") + u; +} + function atou(b64) { return decodeURIComponent(escape(atob(b64))); } function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } @@ -80,7 +95,7 @@ export default async (request, context) => { if (info.u) { info.u = decodeURL(info.u) - if (!info.u.startsWith("http")) info.u = "https://" + info.u; + if (!hasScheme(info.u)) info.u = defaultScheme(info.u); content.push(mProp("og:url", info.u)); content.push(`<script>location.href="${info.u}"</script>`); } else { From 3f6fa414f1ae3a8efb7eea6cfdfcb1d378270eaa Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 19 Sep 2026 14:04:53 +0000 Subject: [PATCH 2/2] Treat localhost and .local as local hosts too Numeric hosts already defaulted to http. localhost, *.localhost and *.local names are this machine or a device on the local network and are in the same position: they rarely have certificates, so https just fails to connect. The host test is now case-insensitive. Two bugs found while testing this. localhost:3000 was getting no scheme at all. hasScheme only recognised a host and port when the name was dotted, so "localhost:" read as a scheme, the same trap "example.com:8080" fell into. The rule now also accepts localhost as a host name, while tel:5551234 stays a scheme. A short single-label hostname could be decoded as base64 and mangled: "mylocal" decoded to binary and produced https://)hq. A bare token is now only accepted as base64 when it decodes to printable text containing a dot or a colon, which every real encoded URL does. This predates the scheme work; it was only visible once bare hostnames were being tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nP4CW7aH6ei3eHbBb1oYd --- README.md | 13 ++++++++++--- docs/forward.js | 23 ++++++++++++++++------- docs/index.html | 16 ++++++++++------ netlify/edge-functions/metadata.js | 21 ++++++++++++++------- 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index bed8d0e..9ac8d3e 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,17 @@ A target with no scheme gets one, chosen by what the host looks like: | `example.com` | `https://example.com` | | `example.com:8080` | `https://example.com:8080` | | `192.168.1.50:8123` | `http://192.168.1.50:8123` | +| `localhost:3000` | `http://localhost:3000` | +| `nas1.local` | `http://nas1.local` | -Numeric hosts are devices on the local network, which rarely have -certificates; named hosts should be https. A host and port is not mistaken for -a scheme, and `tel:5551234` still is one. +Numeric hosts, `localhost` and `.local` names are this machine or a device on +the local network, which rarely have certificates. Everything else gets https. + +Telling a host and port from a scheme takes some care, since `example.com` is +made of characters that are legal in a scheme. It reads as a host and port only +when the name is dotted or is `localhost` *and* what follows the colon is +purely a number — so `localhost:3000` is a host, and `tel:5551234` is still a +scheme. ## Fallbacks diff --git a/docs/forward.js b/docs/forward.js index 5149a78..1eb319e 100644 --- a/docs/forward.js +++ b/docs/forward.js @@ -26,22 +26,31 @@ function decodeURL(s) { if (s.charAt(0) === "." || s.charAt(0) === "/") return s; // A hostname or path is never base64, so only try atob on a bare token. if (s.indexOf(".") >= 0 || s.indexOf("/") >= 0) return s; - try { return atob(s.replace(/=/g, "")); } catch (e) { return s; } + try { + var decoded = atob(s.replace(/=/g, "")); + // "intranet" is valid base64 by accident and decodes to bytes, so only + // accept a decode that came out as text and looks like a URL. + return (/^[\x20-\x7e]+$/.test(decoded) && /[.:]/.test(decoded)) ? decoded : s; + } catch (e) { return s; } } function hasScheme(u) { var m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(u); if (!m) return false; - // "example.com:8080" is a host and port, not a scheme. "tel:5551234" is a - // scheme, so only treat it as a port when the part before the colon is dotted. - if (m[1].indexOf(".") >= 0 && /^\d+([/?#]|$)/.test(m[2])) return false; + // "example.com:8080" and "localhost:3000" are a host and port, not a scheme. + // "tel:5551234" is a scheme, so only treat it as a port when the part before + // the colon is a dotted name or localhost. + var name = m[1].toLowerCase(); + if ((name.indexOf(".") >= 0 || name === "localhost") && /^\d+([/?#]|$)/.test(m[2])) return false; return true; } function defaultScheme(u) { - var host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0]; - // A numeric host is a device on the network, which rarely has a certificate. - return (/^[0-9.]+$/.test(host) ? "http://" : "https://") + u; + var host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0].toLowerCase(); + // Numeric hosts, localhost and .local names are this machine or a device on + // the local network, which rarely have certificates. + var local = /^[0-9.]+$/.test(host) || host === "localhost" || /\.(local|localhost)$/.test(host); + return (local ? "http://" : "https://") + u; } function normalizeTarget(v) { diff --git a/docs/index.html b/docs/index.html index 2459e72..88e6889 100644 --- a/docs/index.html +++ b/docs/index.html @@ -10,16 +10,20 @@ function hasScheme(u) { var m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(u); if (!m) return false; - // "example.com:8080" is a host and port, not a scheme. "tel:5551234" is a - // scheme, so only treat it as a port when the part before the colon is dotted. - if (m[1].indexOf(".") >= 0 && /^\d+([/?#]|$)/.test(m[2])) return false; + // "example.com:8080" and "localhost:3000" are a host and port, not a scheme. + // "tel:5551234" is a scheme, so only treat it as a port when the part before + // the colon is a dotted name or localhost. + var name = m[1].toLowerCase(); + if ((name.indexOf(".") >= 0 || name === "localhost") && /^\d+([/?#]|$)/.test(m[2])) return false; return true; } function defaultScheme(u) { - var host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0]; - // A numeric host is a device on the network, which rarely has a certificate. - return (/^[0-9.]+$/.test(host) ? "http://" : "https://") + u; + var host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0].toLowerCase(); + // Numeric hosts, localhost and .local names are this machine or a device on + // the local network, which rarely have certificates. + var local = /^[0-9.]+$/.test(host) || host === "localhost" || /\.(local|localhost)$/.test(host); + return (local ? "http://" : "https://") + u; } function askForDestination(url) { diff --git a/netlify/edge-functions/metadata.js b/netlify/edge-functions/metadata.js index d40591a..31c39d7 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -8,7 +8,10 @@ function decodeURL(s) { if (s.startsWith(".")) return s; if (s.startsWith("/")) return s; try { - return atob(s.replace(/=/g,'')) + let decoded = atob(s.replace(/=/g,'')) + // "intranet" is valid base64 by accident and decodes to bytes, so only + // accept a decode that came out as text and looks like a URL. + return (/^[\x20-\x7e]+$/.test(decoded) && /[.:]/.test(decoded)) ? decoded : s; } catch (e) { return s; } @@ -17,16 +20,20 @@ function decodeURL(s) { function hasScheme(u) { let m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(u); if (!m) return false; - // "example.com:8080" is a host and port, not a scheme. "tel:5551234" is a - // scheme, so only treat it as a port when the part before the colon is dotted. - if (m[1].indexOf(".") >= 0 && /^\d+([/?#]|$)/.test(m[2])) return false; + // "example.com:8080" and "localhost:3000" are a host and port, not a scheme. + // "tel:5551234" is a scheme, so only treat it as a port when the part before + // the colon is a dotted name or localhost. + let name = m[1].toLowerCase(); + if ((name.indexOf(".") >= 0 || name === "localhost") && /^\d+([/?#]|$)/.test(m[2])) return false; return true; } function defaultScheme(u) { - let host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0]; - // A numeric host is a device on the network, which rarely has a certificate. - return (/^[0-9.]+$/.test(host) ? "http://" : "https://") + u; + let host = u.split("/")[0].split("?")[0].split("#")[0].split(":")[0].toLowerCase(); + // Numeric hosts, localhost and .local names are this machine or a device on + // the local network, which rarely have certificates. + let local = /^[0-9.]+$/.test(host) || host === "localhost" || /\.(local|localhost)$/.test(host); + return (local ? "http://" : "https://") + u; } function atou(b64) { return decodeURIComponent(escape(atob(b64))); }