Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 172 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,172 @@
# redirect.app
# 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 <https://www.redirect.app>. The apex 301s to `www`.

## Routes

| Route | What it does |
|---|---|
| `/#<url>` | Forward to a URL. `?FALLBACK=` adds a second chance — see below. |
| `/<Title>/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.
28 changes: 26 additions & 2 deletions docs/forward.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,39 @@ 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) {
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;
}

Expand Down
21 changes: 20 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down
26 changes: 24 additions & 2 deletions netlify/edge-functions/metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))); }

Expand Down Expand Up @@ -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 {
Expand Down