simplify v2 migration history
This commit is contained in:
1 parent
6ae109e8f5
commit
7a2d6009f7
34 files changed
+725
-9091
No files matched your search
@@ -29,7 +29,6 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"pocketbase": "catalog:",
|
||||
"react": "catalog:",
|
||||
"tr46": "6.0.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { PocketBaseUrl } from "./pocketbase-url";
|
||||
|
||||
describe("PocketBase URL compatibility", () => {
|
||||
test.each([
|
||||
"https://example.com",
|
||||
"https://example.com.",
|
||||
"https://münich.example",
|
||||
"https://xn--mnich-kva.example",
|
||||
"http://example.com:0",
|
||||
"http://example.com:",
|
||||
"http://example.com:80",
|
||||
"http://[::1]:8090",
|
||||
"http://[::ffff:192.168.1.1]",
|
||||
])("accepts canonical URL %s", (value) => {
|
||||
expect(URL.canParse(value)).toBe(true);
|
||||
expect(PocketBaseUrl.canParse(value)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://",
|
||||
"https://:",
|
||||
"http://1.2.3.4.5",
|
||||
"http://09.0.0.1",
|
||||
"http://099.0.0.1",
|
||||
"http://[:::1]",
|
||||
"http://[:1:2:3:4:5:6:7:8]",
|
||||
"http://[1:2:3:4:5:6:7:8:]",
|
||||
"http://[192.168.1.1::]",
|
||||
"http://[::ffff:192.168.001.001]",
|
||||
"http://[::ffff:192.168.1.01]",
|
||||
"http://[fe80::1%25eth0]",
|
||||
"http://example.com:65536",
|
||||
"https://xn--a.example",
|
||||
"https://xn--0.example",
|
||||
"https://xn--abc.example",
|
||||
])("rejects canonical invalid URL %s", (value) => {
|
||||
expect(URL.canParse(value)).toBe(false);
|
||||
expect(PocketBaseUrl.canParse(value)).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://127.1",
|
||||
"http://2130706433",
|
||||
"http://0x7f000001",
|
||||
"http://0177.0.0.1",
|
||||
"http://1.2.3",
|
||||
"http://%31%32%37.0.0.1",
|
||||
"https://%65xample.com",
|
||||
"https://example%2ecom",
|
||||
"https://example.com\\@attacker.invalid",
|
||||
"https:\\\\example.com\\api",
|
||||
"https://-example.com",
|
||||
"https://example-.com",
|
||||
"https://example..com",
|
||||
"https://example.com:00080",
|
||||
"https://example.com:0000000065535",
|
||||
"https://example。com",
|
||||
"https://127.0.0.1",
|
||||
" https://example.com",
|
||||
"https://exa\tmple.com",
|
||||
"https://example.com/path\u00a0",
|
||||
])("conservatively rejects WHATWG-normalized URL %s", (value) => {
|
||||
expect(URL.canParse(value)).toBe(true);
|
||||
expect(PocketBaseUrl.canParse(value)).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizes international hostnames like the native parser", () => {
|
||||
expect(new PocketBaseUrl("https://münich.example").hostname).toBe("xn--mnich-kva.example");
|
||||
expect(new PocketBaseUrl("https://例え.テスト.").hostname).toBe("xn--r8jz45g.xn--zckzah.");
|
||||
});
|
||||
|
||||
test("normalizes IPv6 hostnames like the native parser", () => {
|
||||
expect(new PocketBaseUrl("http://[1:2:3:4:5:6:7::]").hostname).toBe("[1:2:3:4:5:6:7:0]");
|
||||
expect(new PocketBaseUrl("http://[::ffff:192.168.1.1]").hostname).toBe("[::ffff:c0a8:101]");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"https://example.com",
|
||||
"https://example.com.",
|
||||
"https://münich.example",
|
||||
"https://例え.テスト",
|
||||
"https://xn--r8jz45g.xn--zckzah",
|
||||
"http://192.168.1.1:0",
|
||||
"https://[2001:db8::1]:65535",
|
||||
"https://[::ffff:192.168.1.1]",
|
||||
"http://0.0.0.0",
|
||||
"http://255.255.255.255",
|
||||
"https://example.com:",
|
||||
"https://example.com:0",
|
||||
"https://example.com:65535",
|
||||
"http://[::]",
|
||||
"http://[::1]",
|
||||
"http://[1:2:3:4:5:6:7:8]",
|
||||
"http://[1:2:3:4:5:6:7::]",
|
||||
"https://faß.de",
|
||||
"https://xn--fa-hia.de",
|
||||
"https://مثال.إختبار",
|
||||
"https://a_b.example",
|
||||
"https://example.com/%5c@evil",
|
||||
"https://example.com/%2f%2fevil",
|
||||
"https://example.com/icon.svg?next=https%3A%2F%2Fevil#preview",
|
||||
"http://127.1",
|
||||
"http://2130706433",
|
||||
"http://0x7f000001",
|
||||
"http://0xffffffff",
|
||||
"http://0x100000000",
|
||||
"http://0177.0.0.1",
|
||||
"http://0377.0377.0377.0377",
|
||||
"http://1.2.3",
|
||||
"http://1.",
|
||||
"http://1..2",
|
||||
"http://256.0.0.1",
|
||||
"http://00.0.0.0",
|
||||
"http://%31%32%37.0.0.1",
|
||||
"https://%65xample.com",
|
||||
"https://example%2ecom",
|
||||
"https://example.com\\@attacker.invalid",
|
||||
"https:\\\\example.com\\api",
|
||||
" https://example.com",
|
||||
"https://exa\tmple.com",
|
||||
"https://example.com\n",
|
||||
"https://example.com\u00a0",
|
||||
"https://@example.com",
|
||||
"https://:@example.com",
|
||||
"https://example。com",
|
||||
"https://127.0.0.1",
|
||||
"https://-example.com",
|
||||
"https://example-.com",
|
||||
"https://example..com",
|
||||
"https://example.com:01",
|
||||
"https://example.com:00080",
|
||||
"https://example.com:0000000065535",
|
||||
"https://example.com:65536",
|
||||
"https://example.com:655350",
|
||||
"https://[fe80::1%25eth0]",
|
||||
"https://[::ffff:192.168.001.001]",
|
||||
"https://[1:2:3:4:5:6:7:8:9]",
|
||||
"https://\u200d.example",
|
||||
"http:example.com",
|
||||
"https:/example.com",
|
||||
"https:///example.com",
|
||||
"ftp://example.com",
|
||||
"file:///tmp/icon.svg",
|
||||
"https://user:password@example.com",
|
||||
])("keeps canonical source and icon validation in parity for %s", (value) => {
|
||||
const validate = (UrlImplementation: typeof URL, field: "source" | "icon") => {
|
||||
const runtime = globalThis as unknown as { URL: typeof URL };
|
||||
const originalUrl = runtime.URL;
|
||||
runtime.URL = UrlImplementation;
|
||||
try {
|
||||
const definition =
|
||||
field === "source"
|
||||
? {
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
sources: {
|
||||
default: { ...CUSTOM_WIDGET_STARTER.sources.default, baseUrl: value },
|
||||
},
|
||||
}
|
||||
: { ...CUSTOM_WIDGET_STARTER, iconUrl: value };
|
||||
return customWidgetDefinitionSchema.safeParse(definition).success;
|
||||
} finally {
|
||||
runtime.URL = originalUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const nativeUrl = URL;
|
||||
expect(validate(PocketBaseUrl as unknown as typeof URL, "source")).toBe(validate(nativeUrl, "source"));
|
||||
expect(validate(PocketBaseUrl as unknown as typeof URL, "icon")).toBe(validate(nativeUrl, "icon"));
|
||||
});
|
||||
|
||||
test("exposes the URL fields used by the canonical schema", () => {
|
||||
const url = new PocketBaseUrl("https://user:password@example.com:8443/icon.svg?token=secret&mode=dark#icon");
|
||||
expect(url).toMatchObject({
|
||||
protocol: "https:",
|
||||
username: "user",
|
||||
password: "password",
|
||||
hostname: "example.com",
|
||||
port: "8443",
|
||||
search: "?token=secret&mode=dark",
|
||||
hash: "#icon",
|
||||
});
|
||||
expect(url.searchParams.keys()).toEqual(["token", "mode"]);
|
||||
});
|
||||
|
||||
test("validates credential queries without iterable URLSearchParams", () => {
|
||||
const runtime = globalThis as unknown as { URL: typeof URL };
|
||||
const originalUrl = runtime.URL;
|
||||
runtime.URL = PocketBaseUrl as unknown as typeof URL;
|
||||
try {
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
iconUrl: "https://example.com/icon.svg?clientSecret=must-never-be-published",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: {
|
||||
default: {
|
||||
...CUSTOM_WIDGET_STARTER.requests.default,
|
||||
path: "/api/status?key=status&auth=none",
|
||||
},
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
} finally {
|
||||
runtime.URL = originalUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import { toASCII } from "tr46";
|
||||
|
||||
interface PortableSearchParams {
|
||||
keys(): string[];
|
||||
}
|
||||
|
||||
interface ParsedAuthority {
|
||||
username: string;
|
||||
password: string;
|
||||
hostname: string;
|
||||
port: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PocketBase's Goja runtime intentionally has no browser URL global. The
|
||||
* canonical Custom Widget schema uses the small subset implemented here.
|
||||
*/
|
||||
export class PocketBaseUrl {
|
||||
public readonly protocol: string;
|
||||
public readonly username: string;
|
||||
public readonly password: string;
|
||||
public readonly hostname: string;
|
||||
public readonly port: string;
|
||||
public readonly search: string;
|
||||
public readonly hash: string;
|
||||
public readonly origin: string;
|
||||
public readonly searchParams: PortableSearchParams;
|
||||
|
||||
public static canParse(value: unknown): boolean {
|
||||
try {
|
||||
const parsed = new PocketBaseUrl(value);
|
||||
void parsed;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public constructor(value: unknown) {
|
||||
if (typeof value !== "string" || hasForbiddenUrlCharacter(value) || value.includes("\\")) {
|
||||
throw new TypeError("Invalid URL");
|
||||
}
|
||||
|
||||
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/u);
|
||||
if (!scheme?.[1]) throw new TypeError("Invalid URL");
|
||||
this.protocol = `${scheme[1].toLowerCase()}:`;
|
||||
|
||||
const afterScheme = value.slice(scheme[0].length);
|
||||
const hashIndex = afterScheme.indexOf("#");
|
||||
const withoutHash = hashIndex < 0 ? afterScheme : afterScheme.slice(0, hashIndex);
|
||||
this.hash = hashIndex < 0 ? "" : afterScheme.slice(hashIndex);
|
||||
const searchIndex = withoutHash.indexOf("?");
|
||||
const withoutSearch = searchIndex < 0 ? withoutHash : withoutHash.slice(0, searchIndex);
|
||||
this.search = searchIndex < 0 ? "" : withoutHash.slice(searchIndex);
|
||||
this.searchParams = createSearchParams(this.search);
|
||||
|
||||
const requiresAuthority = this.protocol === "http:" || this.protocol === "https:" || this.protocol === "ftp:";
|
||||
if (!withoutSearch.startsWith("//")) {
|
||||
if (requiresAuthority) throw new TypeError("Invalid URL");
|
||||
this.username = "";
|
||||
this.password = "";
|
||||
this.hostname = "";
|
||||
this.port = "";
|
||||
this.origin = "null";
|
||||
return;
|
||||
}
|
||||
|
||||
const authorityAndPath = withoutSearch.slice(2);
|
||||
const slashIndex = authorityAndPath.indexOf("/");
|
||||
const authority = slashIndex < 0 ? authorityAndPath : authorityAndPath.slice(0, slashIndex);
|
||||
const parsed = parseAuthority(authority);
|
||||
this.username = parsed.username;
|
||||
this.password = parsed.password;
|
||||
this.hostname = parsed.hostname;
|
||||
this.port = parsed.port;
|
||||
this.origin = `${this.protocol}//${this.hostname}${this.port ? `:${this.port}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function installPocketBaseUrlPolyfill(): void {
|
||||
const runtime = globalThis as unknown as Record<string, unknown>;
|
||||
if (typeof runtime.URL === "undefined") runtime.URL = PocketBaseUrl;
|
||||
}
|
||||
|
||||
function parseAuthority(authority: string): ParsedAuthority {
|
||||
if (!authority || /[。.。]/u.test(authority)) throw new TypeError("Invalid URL");
|
||||
const atIndex = authority.lastIndexOf("@");
|
||||
const credentials = atIndex < 0 ? "" : authority.slice(0, atIndex);
|
||||
const hostAndPort = atIndex < 0 ? authority : authority.slice(atIndex + 1);
|
||||
if (!hostAndPort) throw new TypeError("Invalid URL");
|
||||
|
||||
const credentialSeparator = credentials.indexOf(":");
|
||||
const username = credentialSeparator < 0 ? credentials : credentials.slice(0, credentialSeparator);
|
||||
const password = credentialSeparator < 0 ? "" : credentials.slice(credentialSeparator + 1);
|
||||
|
||||
let hostname: string;
|
||||
let port = "";
|
||||
if (hostAndPort.startsWith("[")) {
|
||||
const closeIndex = hostAndPort.indexOf("]");
|
||||
if (closeIndex < 0) throw new TypeError("Invalid URL");
|
||||
const address = hostAndPort.slice(1, closeIndex);
|
||||
const normalizedAddress = normalizeIpv6(address);
|
||||
if (normalizedAddress === null) throw new TypeError("Invalid URL");
|
||||
hostname = `[${normalizedAddress}]`;
|
||||
const suffix = hostAndPort.slice(closeIndex + 1);
|
||||
if (suffix) {
|
||||
if (!suffix.startsWith(":")) throw new TypeError("Invalid URL");
|
||||
port = suffix.length === 1 ? "" : validatePort(suffix.slice(1));
|
||||
}
|
||||
} else {
|
||||
const firstColon = hostAndPort.indexOf(":");
|
||||
const lastColon = hostAndPort.lastIndexOf(":");
|
||||
if (firstColon !== lastColon) throw new TypeError("Invalid URL");
|
||||
hostname = firstColon < 0 ? hostAndPort : hostAndPort.slice(0, firstColon);
|
||||
const portValue = firstColon < 0 ? "" : hostAndPort.slice(firstColon + 1);
|
||||
port = portValue ? validatePort(portValue) : "";
|
||||
const normalizedHostname = normalizeHostname(hostname);
|
||||
if (!normalizedHostname) throw new TypeError("Invalid URL");
|
||||
hostname = normalizedHostname;
|
||||
}
|
||||
|
||||
return { username, password, hostname: hostname.toLowerCase(), port };
|
||||
}
|
||||
|
||||
function validatePort(value: string): string {
|
||||
if (!/^(?:0|[1-9]\d{0,4})$/u.test(value)) throw new TypeError("Invalid URL");
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port > 65_535) throw new TypeError("Invalid URL");
|
||||
return String(port);
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string): string | null {
|
||||
const ascii = toASCII(value, {
|
||||
checkHyphens: false,
|
||||
checkBidi: true,
|
||||
checkJoiners: true,
|
||||
useSTD3ASCIIRules: false,
|
||||
transitionalProcessing: false,
|
||||
verifyDNSLength: false,
|
||||
ignoreInvalidPunycode: false,
|
||||
});
|
||||
if (!ascii || hasForbiddenUrlCharacter(ascii) || /[#%/:<>?@[\\\]^|]/u.test(ascii)) return null;
|
||||
const candidate = ascii.endsWith(".") ? ascii.slice(0, -1) : ascii;
|
||||
if (!candidate || candidate.length > 253 || !/^[A-Za-z0-9._-]+$/u.test(candidate)) return null;
|
||||
if (looksNumeric(candidate)) {
|
||||
const rawCandidate = value.endsWith(".") ? value.slice(0, -1) : value;
|
||||
return isStrictIpv4(candidate) && rawCandidate === candidate ? ascii.toLowerCase() : null;
|
||||
}
|
||||
const isValid = candidate
|
||||
.split(".")
|
||||
.every((part) => Boolean(part) && part.length <= 63 && !part.startsWith("-") && !part.endsWith("-"));
|
||||
return isValid ? ascii.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function hasForbiddenUrlCharacter(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return (
|
||||
code <= 0x20 ||
|
||||
(code >= 0x7f && code <= 0xa0) ||
|
||||
code === 0x1680 ||
|
||||
(code >= 0x2000 && code <= 0x200f) ||
|
||||
(code >= 0x2028 && code <= 0x202f) ||
|
||||
(code >= 0x205f && code <= 0x206f) ||
|
||||
code === 0x3000 ||
|
||||
code === 0xfeff
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeIpv6(value: string): string | null {
|
||||
if (value.includes("%")) return null;
|
||||
const address = value;
|
||||
if (!address.includes(":") || address.includes(":::") || /[^0-9A-Fa-f:.]/u.test(address)) return null;
|
||||
if (address.indexOf("::") !== address.lastIndexOf("::")) return null;
|
||||
if ((address.startsWith(":") && !address.startsWith("::")) || (address.endsWith(":") && !address.endsWith("::"))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasCompression = address.includes("::");
|
||||
const [left = "", right = ""] = hasCompression ? address.split("::") : [address, ""];
|
||||
if (hasCompression && left.includes(".")) return null;
|
||||
const leftGroups = parseIpv6Groups(left);
|
||||
const rightGroups = parseIpv6Groups(right);
|
||||
if (leftGroups === null || rightGroups === null) return null;
|
||||
|
||||
const explicitGroupCount = leftGroups.length + rightGroups.length;
|
||||
if ((!hasCompression && explicitGroupCount !== 8) || (hasCompression && explicitGroupCount >= 8)) return null;
|
||||
|
||||
const groups = hasCompression
|
||||
? [...leftGroups, ...Array.from({ length: 8 - explicitGroupCount }, () => 0), ...rightGroups]
|
||||
: leftGroups;
|
||||
let bestStart = -1;
|
||||
let bestLength = 1;
|
||||
for (let index = 0; index < groups.length;) {
|
||||
if (groups[index] !== 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let end = index + 1;
|
||||
while (end < groups.length && groups[end] === 0) end += 1;
|
||||
if (end - index > bestLength) {
|
||||
bestStart = index;
|
||||
bestLength = end - index;
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
|
||||
const hexadecimalGroups = groups.map((group) => group.toString(16));
|
||||
if (bestStart < 0) return hexadecimalGroups.join(":");
|
||||
const before = hexadecimalGroups.slice(0, bestStart).join(":");
|
||||
const after = hexadecimalGroups.slice(bestStart + bestLength).join(":");
|
||||
return `${before}::${after}`;
|
||||
}
|
||||
|
||||
function parseIpv6Groups(value: string): number[] | null {
|
||||
if (!value) return [];
|
||||
const groups = value.split(":");
|
||||
const parsed: number[] = [];
|
||||
for (const [index, group] of groups.entries()) {
|
||||
if (group.includes(".")) {
|
||||
if (index !== groups.length - 1 || !isStrictIpv4(group)) return null;
|
||||
const parts = group.split(".").map(Number);
|
||||
parsed.push(((parts[0] ?? 0) << 8) | (parts[1] ?? 0), ((parts[2] ?? 0) << 8) | (parts[3] ?? 0));
|
||||
} else {
|
||||
if (!/^[0-9A-Fa-f]{1,4}$/u.test(group)) return null;
|
||||
parsed.push(Number.parseInt(group, 16));
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isStrictIpv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9]\d{0,2})$/u.test(part) && Number(part) <= 255);
|
||||
}
|
||||
|
||||
function looksNumeric(value: string): boolean {
|
||||
return value.split(".").every((part) => /^(?:\d+|0[xX][0-9A-Fa-f]+)$/u.test(part));
|
||||
}
|
||||
|
||||
function createSearchParams(search: string): PortableSearchParams {
|
||||
const keys = search
|
||||
? search
|
||||
.slice(1)
|
||||
.split("&")
|
||||
.map((entry) => entry.split("=", 1)[0] ?? "")
|
||||
.map(decodeQueryPart)
|
||||
: [];
|
||||
return { keys: () => [...keys] };
|
||||
}
|
||||
|
||||
function decodeQueryPart(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value.replaceAll("+", " "));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
declare module "tr46" {
|
||||
interface ToAsciiOptions {
|
||||
checkHyphens?: boolean;
|
||||
checkBidi?: boolean;
|
||||
checkJoiners?: boolean;
|
||||
useSTD3ASCIIRules?: boolean;
|
||||
transitionalProcessing?: boolean;
|
||||
verifyDNSLength?: boolean;
|
||||
ignoreInvalidPunycode?: boolean;
|
||||
}
|
||||
|
||||
export function toASCII(value: string, options?: ToAsciiOptions): string | null;
|
||||
}
|
||||
Reference in new issue
Block a user