// 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}` }; }