An instance may be on another host, behind a reverse proxy on 443 under a path prefix, on a tailnet address, or on an arbitrary port because the usual one was taken. All ordinary self-hosted setups, and each one a case where assuming otherwise produces a connection that fails later with no clue why. Two places were sloppy about it. Jellyfin's placeholder read `http://localhost:8096` and Transmission's `http://localhost:9091`, which quietly teach that a service must be local and on its project's default port; both now show remote examples, and the field type says why. And nothing validated what was typed, so a bare hostname or a URL with a token in the query string was stored as-is. The rule: reject only what cannot work, normalise what is merely untidy, have no opinion about the rest. No check that the host is local, that the port matches a default, or that the scheme is https — a tailnet HTTP service is completely normal. Trailing slashes go, because `${url}/api/x` otherwise doubles the separator: accepted by some servers and 404 by others, which is the kind of difference that reproduces on one machine and not another. Query strings go, because that is where a token hides, and it would sit in a column meant for a location. Credentials in the URL are refused for the same reason — outside the encrypted secret, and in every log line that ever prints it. A missing scheme is named rather than called invalid: it is the commonest mistake, because it is what people type into a browser. Verified through the API: `memos.example.com` is blocked with the fix quoted back, and `https://memos.example.com:8443/memos/` installs and stores normalised — remote host, non-standard port, path prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
59 lines
2.8 KiB
TypeScript
59 lines
2.8 KiB
TypeScript
// Checking and normalising a URL the user typed for a service they already run.
|
|
//
|
|
// ── What must never be assumed ──
|
|
//
|
|
// That the instance is on this machine, on its project's usual port, or reachable without a scheme. It
|
|
// may be on another host, behind a reverse proxy on 443 with a path, on a tailnet address, or on an
|
|
// arbitrary port because 8096 was already taken. Every one of those is an ordinary self-hosted setup,
|
|
// and each is a case where guessing produces a connection that fails later with no clue why.
|
|
//
|
|
// So the rule here is narrow: reject only what CANNOT work, normalise what is merely untidy, and accept
|
|
// everything else without opinion. In particular no check that the host is local, that the port matches
|
|
// the service's default, or that the scheme is https — a tailnet HTTP service is completely normal.
|
|
|
|
export type UrlCheck = { ok: true; url: string } | { ok: false; error: string };
|
|
|
|
/**
|
|
* Validate and normalise. Returns the form to store, or why it cannot be stored.
|
|
*
|
|
* Normalising matters more than it looks: a trailing slash turns `${url}/api/x` into a double
|
|
* separator, which some servers accept and others 404 — the kind of difference that shows up on one
|
|
* user's machine and not another's.
|
|
*/
|
|
export function checkServiceUrl(raw: string): UrlCheck {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return { ok: false, error: 'A URL is required.' };
|
|
|
|
// The commonest mistake, and worth naming precisely rather than as "invalid URL": people type the
|
|
// host alone because that is what they type into a browser.
|
|
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
|
|
return { ok: false, error: `Include the scheme — "https://${trimmed}" or "http://${trimmed}".` };
|
|
}
|
|
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(trimmed);
|
|
} catch {
|
|
return { ok: false, error: 'That is not a valid URL.' };
|
|
}
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return { ok: false, error: `Only http and https are supported, not "${parsed.protocol.replace(':', '')}".` };
|
|
}
|
|
if (!parsed.hostname) return { ok: false, error: 'That URL has no host.' };
|
|
|
|
// Credentials in the URL are dropped rather than stored: they would end up in a database column meant
|
|
// for a location, outside the encrypted `secret`, and in any log line that ever prints the URL.
|
|
if (parsed.username || parsed.password) {
|
|
return {
|
|
ok: false,
|
|
error: 'Put the username and password in their own fields rather than in the URL.',
|
|
};
|
|
}
|
|
|
|
// Trailing slash off, query and fragment dropped — neither means anything for a service base, and a
|
|
// stored `?token=…` would be a credential hiding in the wrong column.
|
|
const path = parsed.pathname.replace(/\/+$/, '');
|
|
return { ok: true, url: `${parsed.protocol}//${parsed.host}${path}` };
|
|
}
|