diff --git a/README.md b/README.md index b52ed62..9ac8d3e 100644 --- a/README.md +++ b/README.md @@ -1 +1,172 @@ -# 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` | +| `localhost:3000` | `http://localhost:3000` | +| `nas1.local` | `http://nas1.local` | + +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 + +`/#<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..1eb319e 100644 --- a/docs/forward.js +++ b/docs/forward.js @@ -26,7 +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" 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].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) { @@ -34,7 +58,7 @@ function normalizeTarget(v) { 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..88e6889 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,6 +7,25 @@ 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" 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].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) { url = prompt([ "This app opens other sites and URLs", @@ -15,7 +34,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..31c39d7 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -8,12 +8,34 @@ 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; } } +function hasScheme(u) { + let m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(u); + if (!m) 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].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))); } function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } @@ -80,7 +102,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 {