feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
interface ReadableResponse {
headers: { get: (name: string) => string | null };
body: {
getReader: () => {
read: () => Promise<{ done: boolean; value?: Uint8Array }>;
cancel: () => Promise<void>;
};
} | null;
}
export async function readBoundedJsonResponseAsync(
response: ReadableResponse,
maxBytes: number,
label = "Response",
): Promise<unknown> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error(`${label} exceeds ${maxBytes} bytes`);
}
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let byteLength = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
byteLength += value.byteLength;
if (byteLength > maxBytes) {
await reader.cancel();
throw new Error(`${label} exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
const body = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(body)) as unknown;
}
+14
View File
@@ -7,6 +7,7 @@ const algorithm = "aes-256-cbc"; //Using AES encryption
// We fallback to a key of 0s if the key was not provided because env validation was skipped
// This should only be the case in CI
const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex");
const fingerprintKey = crypto.createHmac("sha256", key).update("homarr-keyed-fingerprint-v1").digest();
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);
@@ -29,3 +30,16 @@ export function decryptSecretWithKey(value: `${string}.${string}`, key: Buffer)
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
// This HMAC is a server-keyed cache/session identity, never a password verifier.
// Passwords used for authentication are stored separately with bcrypt.
export function createKeyedFingerprint(value: string) {
return crypto.createHmac("sha256", fingerprintKey).update(value).digest("hex");
}
export function verifyKeyedFingerprint(value: string, fingerprint: string) {
const expected = Buffer.from(createKeyedFingerprint(value), "hex");
const actual = Buffer.from(fingerprint, "hex");
if (expected.length !== actual.length) return false;
return crypto.timingSafeEqual(expected, actual);
}
+125
View File
@@ -0,0 +1,125 @@
export interface GridAlgorithmItem {
id: string;
type: "item" | "section";
width: number;
height: number;
xOffset: number;
yOffset: number;
sectionId: string;
}
interface GridAlgorithmInput {
items: GridAlgorithmItem[];
width: number;
previousWidth: number;
sectionId: string;
}
interface GridAlgorithmOutput {
height: number;
items: GridAlgorithmItem[];
}
export const generateResponsiveGridFor = ({
items,
previousWidth,
width,
sectionId,
}: GridAlgorithmInput): GridAlgorithmOutput =>
generateResponsiveGridForSection({ items, previousWidth, width, sectionId }, new Set());
const generateResponsiveGridForSection = (
{ items, previousWidth, width, sectionId }: GridAlgorithmInput,
ancestorSectionIds: ReadonlySet<string>,
): GridAlgorithmOutput => {
if (ancestorSectionIds.has(sectionId)) return { height: 0, items: [] };
const nextAncestorSectionIds = new Set(ancestorSectionIds).add(sectionId);
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.toSorted((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] };
const newItems: GridAlgorithmItem[] = [];
const dynamicSectionHeightMap = new Map<string, number>();
for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) {
const result = generateResponsiveGridForSection(
{
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
},
nextAncestorSectionIds,
);
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
}
if (width >= previousWidth) {
return {
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
items: newItems.concat(normalizedItems),
};
}
const occupied2d: boolean[][] = [];
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
addItemToOccupied(occupied2d, itemWithHeight, position, width);
newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y });
}
return { height: occupied2d.length, items: newItems };
};
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) =>
items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
const addItemToOccupied = (
occupied2d: boolean[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
row = Array.from({ length: columnCount }, () => false);
occupied2d.push(row);
}
for (let xOffset = 0; xOffset < item.width; xOffset++) row[position.x + xOffset] = true;
}
};
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
for (let offsetY = 0; offsetY < 99999; offsetY++) {
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
if (offsetX + item.width <= columnCount && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
return { x: offsetX, y: offsetY };
}
}
}
return null;
};
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
const row = occupied2d[position.y + yOffset];
if (!row) return true;
for (let xOffset = 0; xOffset < item.width; xOffset++) {
if (row[position.x + xOffset]) return false;
}
}
return true;
};
+1 -1
View File
@@ -11,4 +11,4 @@ export * from "./number";
export * from "./error";
export * from "./theme";
export * from "./function";
export * from "./id";
export * from "./grid-algorithm";
+55 -57
View File
@@ -16,53 +16,17 @@ export const formatNumber = (value: number, decimalPlaces: number) => {
return value.toFixed(decimalPlaces);
};
export const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1) + min);
};
/**
* Number of bytes to a human-readable string using binary (KiB) suffixes.
* Does not accept floats; size in bytes should be an integer.
* Will return "NaI" and log a warning if a float is passed.
* `concat` is appended after the unit so it is omitted when the returned
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
* represented in the current format.
*
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
* paired "used / total" displays, and `formatByteRate` for byte-per-second
* rates. This function is kept for backwards compatibility but should not
* be used in new code.
*/
export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB
const siRanges = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
if (!Number.isInteger(size)) {
console.warn(
"Invalid use of the humanFileSize function with a float, please report this and what integration this is impacting.",
);
//Not an Integer
return "NaI";
}
let count = 0;
while (count < siRanges.length) {
const tempSize = size / Math.pow(1024, count);
if (tempSize < 1024) {
return tempSize.toFixed(Math.min(count, 1)) + siRanges[count] + concat;
}
count++;
}
return "∞";
};
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export const defaultByteUnitSystem = "decimal" as const;
export type ByteUnitSystem = "binary" | "decimal";
export interface FormatBytesOptions {
/**
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "decimal".
*/
unit?: ByteUnitSystem;
}
@@ -78,10 +42,17 @@ const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number =
return index;
};
const pickRoundedUnitIndex = (value: number, base: number, lastIndex: number, fractionDigits: number): number => {
const index = pickUnitIndex(value, base, lastIndex);
if (index >= lastIndex) return index;
const scaled = value / base ** index;
return Number(scaled.toFixed(fractionDigits)) >= base ? index + 1 : index;
};
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
const resolveUnitConfig = (options: FormatBytesOptions) => {
const { unit = "binary" } = options;
const { unit = defaultByteUnitSystem } = options;
return {
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
base: unit === "binary" ? 1024 : 1000,
@@ -92,45 +63,45 @@ const resolveUnitConfig = (options: FormatBytesOptions) => {
* Format a byte value as a human-readable string with a unit suffix.
*
* Picks the largest unit that keeps the scaled value below the base. Use
* `formatBytesPair` when two related values (e.g. used and available) should
* `formatBytesPair` when two related values (e.g. used and total) should
* share a common unit.
*
* @example
* formatBytes(0); // "0.0 B"
* formatBytes(1024); // "1.0 KiB"
* formatBytes(985828802560); // "918.1 GiB"
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
* formatBytes(1000); // "1.0 KB"
* formatBytes(985828802560); // "985.8 GB"
* formatBytes(985828802560, { unit: "binary" }); // "918.1 GiB"
*/
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
const { units, base } = resolveUnitConfig(options);
const safe = sanitizeBytes(bytes);
const index = pickUnitIndex(safe, base, units.length - 1);
const index = pickRoundedUnitIndex(safe, base, units.length - 1, 1);
const scaled = safe / base ** index;
return `${scaled.toFixed(1)} ${units[index]}`;
};
/**
* Format two related byte values (typically `used` and `available`) with a
* shared unit. The unit is picked from the sum of the two values so the
* combined display stays consistent.
* Format two related byte values (typically `used` and `total`) with a shared
* unit. The unit is picked from the larger displayed value so the pair stays
* consistent without promoting both values before either crosses a boundary.
*
* @example
* formatBytesPair(985828802560, 2858736793190);
* // { used: "0.9 TiB", available: "2.6 TiB" }
* // { used: "1.0 TB", total: "2.9 TB" }
*/
export const formatBytesPair = (
used: number,
available: number,
total: number,
options: FormatBytesOptions = {},
): { used: string; available: string } => {
): { used: string; total: string } => {
const { units, base } = resolveUnitConfig(options);
const safeUsed = sanitizeBytes(used);
const safeAvailable = sanitizeBytes(available);
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
const safeTotal = sanitizeBytes(total);
const index = pickRoundedUnitIndex(Math.max(safeUsed, safeTotal), base, units.length - 1, 1);
const suffix = units[index];
return {
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
total: `${(safeTotal / base ** index).toFixed(1)} ${suffix}`,
};
};
@@ -141,13 +112,40 @@ export const formatBytesPair = (
*
* @example
* formatByteRate(0); // "0.0 B/s"
* formatByteRate(1024); // "1.0 KiB/s"
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
* formatByteRate(1000); // "1.0 KB/s"
* formatByteRate(985828802560, { unit: "binary" }); // "918.1 GiB/s"
*/
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
`${formatBytes(bytes, options)}/s`;
const BIT_RATE_UNITS = ["b/s", "Kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s"] as const;
export interface FormatBitRateOptions {
/** Maximum number of fractional digits. Trailing zeroes are omitted. Defaults to 1. */
maximumFractionDigits?: number;
}
/**
* Format a bit-per-second rate using decimal SI units.
*
* Inputs are always bits per second. Callers whose upstream contract uses
* kilobits must normalize explicitly before calling this function.
*
* @example
* formatBitRate(999); // "999 b/s"
* formatBitRate(1_000); // "1 Kb/s"
* formatBitRate(1_500_000); // "1.5 Mb/s"
*/
export const formatBitRate = (bitsPerSecond: number, options: FormatBitRateOptions = {}): string => {
const safeRate = Number.isFinite(bitsPerSecond) && bitsPerSecond > 0 ? bitsPerSecond : 0;
const requestedDigits = options.maximumFractionDigits ?? 1;
const maximumFractionDigits = Math.max(0, Math.min(20, Math.trunc(requestedDigits)));
const index = pickRoundedUnitIndex(safeRate, 1000, BIT_RATE_UNITS.length - 1, maximumFractionDigits);
const scaled = safeRate / 1000 ** index;
const formatted = Number(scaled.toFixed(maximumFractionDigits)).toString();
return `${formatted} ${BIT_RATE_UNITS[index]}`;
};
const IMPERIAL_MULTIPLIER = 1.609344;
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
export const imperialToMetric = (imperialValue: number) => imperialValue * IMPERIAL_MULTIPLIER;
+3
View File
@@ -1,5 +1,8 @@
import { hashKey } from "@tanstack/query-core";
export const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
return Object.keys(obj) as (keyof O)[];
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./security";
export * from "./encryption";
export * from "./request";
export * from "./errors";
export * from "./bounded-response";
+58 -34
View File
@@ -1,35 +1,35 @@
import { describe, expect, it } from "vitest";
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
import { formatBitRate, formatByteRate, formatBytes, formatBytesPair } from "../number";
describe("formatBytes", () => {
it("returns bytes for small values", () => {
expect(formatBytes(0)).toBe("0.0 B");
expect(formatBytes(1)).toBe("1.0 B");
expect(formatBytes(1023)).toBe("1023.0 B");
expect(formatBytes(999)).toBe("999.0 B");
});
it("scales binary units correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
it("scales decimal units by default", () => {
expect(formatBytes(1000)).toBe("1.0 KB");
expect(formatBytes(1000 ** 2)).toBe("1.0 MB");
expect(formatBytes(1000 ** 3)).toBe("1.0 GB");
expect(formatBytes(1000 ** 4)).toBe("1.0 TB");
});
it("scales decimal units when requested", () => {
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
it("scales binary units when requested", () => {
expect(formatBytes(1024, { unit: "binary" })).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2, { unit: "binary" })).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3, { unit: "binary" })).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4, { unit: "binary" })).toBe("1.0 TiB");
});
it("formats a typical storage value with one decimal", () => {
expect(formatBytes(985828802560)).toBe("918.1 GiB");
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
expect(formatBytes(985828802560)).toBe("985.8 GB");
expect(formatBytes(985828802560, { unit: "binary" })).toBe("918.1 GiB");
});
it("caps at the largest unit to avoid overflow", () => {
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PB$/);
});
it("returns the zero value for negative or non-finite input", () => {
@@ -40,47 +40,53 @@ describe("formatBytes", () => {
});
describe("formatBytesPair", () => {
it("picks the unit of the larger total and applies it to both values", () => {
const { used, available } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("0.9 TiB");
expect(available).toBe("2.6 TiB");
it("picks the unit of the larger value and applies it to both values", () => {
const { used, total } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("1.0 TB");
expect(total).toBe("2.9 TB");
});
it("uses a smaller unit when both values fit in it", () => {
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("100.0 MiB");
expect(available).toBe("200.0 MiB");
const { used, total } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("104.9 MB");
expect(total).toBe("209.7 MB");
});
it("does not promote the pair when only their sum crosses a unit boundary", () => {
const { used, total } = formatBytesPair(700 * 1024, 700 * 1024);
expect(used).toBe("716.8 KB");
expect(total).toBe("716.8 KB");
});
it("falls back to zero when both inputs are invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, -1);
const { used, total } = formatBytesPair(Number.NaN, -1);
expect(used).toBe("0.0 B");
expect(available).toBe("0.0 B");
expect(total).toBe("0.0 B");
});
it("uses the same unit across both values when one is invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TiB");
expect(available).toBe("1.0 TiB");
const { used, total } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TB");
expect(total).toBe("1.1 TB");
});
it("honours the decimal unit option", () => {
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
const { used, total } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
expect(used).toBe("0.5 TB");
expect(available).toBe("1.5 TB");
expect(total).toBe("1.5 TB");
});
});
describe("formatByteRate", () => {
it("appends a /s suffix to the formatted value", () => {
expect(formatByteRate(0)).toBe("0.0 B/s");
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
expect(formatByteRate(1000)).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 3)).toBe("1.0 GB/s");
});
it("honours the decimal unit option", () => {
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
it("honours the binary unit option", () => {
expect(formatByteRate(1024, { unit: "binary" })).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 2, { unit: "binary" })).toBe("1.0 MiB/s");
});
it("returns zero for invalid input", () => {
@@ -88,3 +94,21 @@ describe("formatByteRate", () => {
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
});
});
describe("formatBitRate", () => {
it("uses decimal SI units with one optional fractional digit", () => {
expect(formatBitRate(999)).toBe("999 b/s");
expect(formatBitRate(1_000)).toBe("1 Kb/s");
expect(formatBitRate(1_000_000)).toBe("1 Mb/s");
expect(formatBitRate(1_500_000)).toBe("1.5 Mb/s");
expect(formatBitRate(1_000_000_000)).toBe("1 Gb/s");
expect(formatBitRate(1_234_567)).toBe("1.2 Mb/s");
});
it("returns zero for non-positive or non-finite rates", () => {
expect(formatBitRate(0)).toBe("0 b/s");
expect(formatBitRate(-1)).toBe("0 b/s");
expect(formatBitRate(Number.NaN)).toBe("0 b/s");
expect(formatBitRate(Number.POSITIVE_INFINITY)).toBe("0 b/s");
});
});
+51
View File
@@ -48,3 +48,54 @@ const absoluteUrlRegex = /^[a-z]+:(\/\/)?/;
export const isAbsoluteUrl = (urlOrPath: string): boolean => {
return absoluteUrlRegex.test(urlOrPath.toLowerCase());
};
export const SAFE_NEW_TAB_REL = "noopener noreferrer";
interface SafeApplicationUrlOptions {
baseUrl?: unknown;
}
/** Returns an absolute, credential-free HTTP(S) URL suitable for application navigation. */
export const getSafeApplicationUrl = (value: unknown, options: SafeApplicationUrlOptions = {}): string | undefined => {
if (typeof value !== "string" || value.trim() === "") return undefined;
try {
let baseUrl: string | undefined;
if (options.baseUrl !== undefined) {
const parsedBase = parseHttpUrl(options.baseUrl);
if (!parsedBase) return undefined;
parsedBase.search = "";
parsedBase.hash = "";
baseUrl = parsedBase.toString();
}
return parseHttpUrl(value, baseUrl)?.toString();
} catch {
return undefined;
}
};
/** Returns a safe user-configured app link, including same-origin paths and custom URI schemes. */
export const getSafeAppHref = (value: unknown): string | undefined => {
if (typeof value !== "string" || value.trim() === "") return undefined;
const href = value.trim();
if (/^(?:javascript|data|vbscript|file|blob):/i.test(href)) return undefined;
try {
const url = new URL(href);
if (url.username !== "" || url.password !== "") return undefined;
return url.toString();
} catch {
return /^(?:[#?]|\/(?![\\/])|\.{1,2}\/)/.test(href) ? href : undefined;
}
};
const parseHttpUrl = (value: unknown, baseUrl?: string): URL | undefined => {
if (typeof value !== "string") return undefined;
const url = new URL(value, baseUrl);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") {
return undefined;
}
return url;
};