first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
/**
* Base64 encoding/decoding utilities.
*
* Uses native Uint8Array.prototype.toBase64 / Uint8Array.fromBase64 when
* available (workerd, Node 26+, modern browsers), falls back to btoa/atob.
*
* All base64url encoding uses the { alphabet: "base64url" } option natively
* or manual character replacement as fallback.
*
* Delete the fallback paths when the minimum Node version supports these
* methods natively.
*/
const hasNative =
typeof Uint8Array.prototype.toBase64 === "function" &&
typeof Uint8Array.fromBase64 === "function";
// Regex patterns for base64url character replacement
const BASE64_PLUS_PATTERN = /\+/g;
const BASE64_SLASH_PATTERN = /\//g;
const BASE64_PADDING_PATTERN = /=+$/;
const BASE64URL_DASH_PATTERN = /-/g;
const BASE64URL_UNDERSCORE_PATTERN = /_/g;
// ---------------------------------------------------------------------------
// Standard base64 (for opaque tokens, cursors, Basic Auth, etc.)
// ---------------------------------------------------------------------------
/** Encode a UTF-8 string as standard base64. */
export function encodeBase64(str: string): string {
const bytes = new TextEncoder().encode(str);
if (hasNative) return bytes.toBase64();
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
/** Decode a standard base64 string to a UTF-8 string. */
export function decodeBase64(base64: string): string {
if (hasNative) return new TextDecoder().decode(Uint8Array.fromBase64(base64));
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
// ---------------------------------------------------------------------------
// Base64url (for tokens, HMAC signatures, PKCE, etc.)
// ---------------------------------------------------------------------------
/** Encode bytes as base64url without padding. */
export function encodeBase64url(bytes: Uint8Array): string {
if (hasNative) return bytes.toBase64({ alphabet: "base64url", omitPadding: true });
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary)
.replace(BASE64_PLUS_PATTERN, "-")
.replace(BASE64_SLASH_PATTERN, "_")
.replace(BASE64_PADDING_PATTERN, "");
}
/** Decode a base64url string (with or without padding) to bytes. */
export function decodeBase64url(encoded: string): Uint8Array {
if (hasNative) return Uint8Array.fromBase64(encoded, { alphabet: "base64url" });
const base64 = encoded
.replace(BASE64URL_DASH_PATTERN, "+")
.replace(BASE64URL_UNDERSCORE_PATTERN, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}

View File

@@ -0,0 +1,36 @@
/**
* SHA-256 hash of a string, truncated to 16 hex chars (64 bits).
* For cache invalidation / ETags — not for security.
*/
export async function hashString(content: string): Promise<string> {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
return Array.from(new Uint8Array(buf).slice(0, 8), (b) => b.toString(16).padStart(2, "0")).join(
"",
);
}
/**
* Compute content hash using Web Crypto API
*
* Uses SHA-1 which is the fastest option in SubtleCrypto.
* SHA-1 is cryptographically weak but fine for content deduplication
* where we only need to detect identical files, not resist attacks.
*
* Returns hex string prefixed with "sha1:" for future-proofing
*/
export async function computeContentHash(content: Uint8Array | ArrayBuffer): Promise<string> {
// SubtleCrypto.digest() requires BufferSource (ArrayBuffer | ArrayBufferView<ArrayBuffer>).
// Uint8Array.buffer is ArrayBufferLike which may include SharedArrayBuffer in the type system,
// so we ensure we have a plain ArrayBuffer.
let buf: ArrayBuffer;
if (content instanceof ArrayBuffer) {
buf = content;
} else {
buf = new ArrayBuffer(content.byteLength);
new Uint8Array(buf).set(content);
}
const hashBuffer = await crypto.subtle.digest("SHA-1", buf);
const hashArray = new Uint8Array(hashBuffer);
const hashHex = Array.from(hashArray, (b) => b.toString(16).padStart(2, "0")).join("");
return `sha1:${hashHex}`;
}

View File

@@ -0,0 +1,20 @@
import sanitizeHtml from "sanitize-html";
/**
* Sanitize HTML content to prevent XSS attacks.
*
* Allows standard formatting tags, images, iframes (from specific providers),
* and basic attributes.
*/
export function sanitizeContent(html: string): string {
return sanitizeHtml(html, {
allowedTags: [...sanitizeHtml.defaults.allowedTags, "img", "span", "iframe"],
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
"*": ["class", "id", "data-*"],
iframe: ["src", "width", "height", "frameborder", "allow", "allowfullscreen"],
img: ["src", "srcset", "alt", "title", "width", "height", "loading"],
},
allowedIframeHostnames: ["www.youtube.com", "player.vimeo.com"],
});
}

View File

@@ -0,0 +1,29 @@
// Regex patterns for slug normalization
const DIACRITICS_PATTERN = /[\u0300-\u036f]/g;
const WHITESPACE_UNDERSCORE_PATTERN = /[\s_]+/g;
const NON_ALPHANUMERIC_HYPHEN_PATTERN = /[^a-z0-9-]/g;
const MULTIPLE_HYPHENS_PATTERN = /-+/g;
const LEADING_TRAILING_HYPHEN_PATTERN = /^-|-$/g;
const TRAILING_HYPHEN_PATTERN = /-$/;
/**
* Convert a string to a URL-friendly slug.
*
* Handles unicode by normalizing to NFD and stripping diacritics,
* so "café" becomes "cafe", "naïve" becomes "naive", etc.
*/
export function slugify(text: string, maxLength: number = 80): string {
return (
text
.toLowerCase()
.normalize("NFD")
.replace(DIACRITICS_PATTERN, "")
.replace(WHITESPACE_UNDERSCORE_PATTERN, "-")
.replace(NON_ALPHANUMERIC_HYPHEN_PATTERN, "")
.replace(MULTIPLE_HYPHENS_PATTERN, "-")
.replace(LEADING_TRAILING_HYPHEN_PATTERN, "")
.slice(0, maxLength)
// Clean trailing hyphen from truncation
.replace(TRAILING_HYPHEN_PATTERN, "")
);
}

View File

@@ -0,0 +1,48 @@
/**
* URL scheme validation utilities
*
* Prevents XSS via dangerous URL schemes (javascript:, data:, vbscript:, etc.)
* by allowlisting known-safe schemes before rendering into href attributes.
*/
/**
* Matches URLs that are safe to render in href attributes.
*
* Allowed:
* - http:// and https://
* - mailto: and tel:
* - Relative paths (starting with /)
* - Fragment links (starting with #)
* - Protocol-relative URLs are NOT allowed (starting with //) as they can
* redirect to attacker-controlled hosts.
*/
const SAFE_URL_SCHEME_RE = /^(https?:|mailto:|tel:|\/(?!\/)|#)/i;
/**
* Returns the URL unchanged if it uses a safe scheme, otherwise returns "#".
*
* Use this at the render layer as the primary defense against XSS via
* dangerous URL schemes like `javascript:`, `data:`, or `vbscript:`.
*
* @example
* ```ts
* sanitizeHref("https://example.com") // "https://example.com"
* sanitizeHref("/about") // "/about"
* sanitizeHref("#section") // "#section"
* sanitizeHref("mailto:a@b.com") // "mailto:a@b.com"
* sanitizeHref("javascript:alert(1)") // "#"
* sanitizeHref("data:text/html,<script>") // "#"
* sanitizeHref("") // "#"
* ```
*/
export function sanitizeHref(url: string | undefined | null): string {
if (!url) return "#";
return SAFE_URL_SCHEME_RE.test(url) ? url : "#";
}
/**
* Returns true if the URL uses a safe scheme for rendering in href attributes.
*/
export function isSafeHref(url: string): boolean {
return SAFE_URL_SCHEME_RE.test(url);
}