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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -11,4 +11,4 @@ export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
export * from "./id";
|
||||
export * from "./grid-algorithm";
|
||||
@@ -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;
|
||||
@@ -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)[];
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./request";
|
||||
export * from "./errors";
|
||||
export * from "./bounded-response";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user