import type { User } from "./types"; export const encoder = new TextEncoder(); export const decoder = new TextDecoder(); export function id(): string { return crypto.randomUUID(); } export function tokenString(bytes: number): string { return base64Url(crypto.getRandomValues(new Uint8Array(bytes))); } export function concatBytes(...parts: Uint8Array[]): Uint8Array { let total = 0; for (const part of parts) total += part.length; const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } export function base64(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary); } export function base64Decode(value: string): Uint8Array { const binary = atob(value); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } export function base64Url(bytes: Uint8Array): string { return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } export function base64UrlDecode(value: string): Uint8Array { const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4); return base64Decode(padded); } export function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; } export function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); } export function safeFileName(value: string): string { return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "upload"; } export function htmlContent(text: string, mentions: { acct: string; url: string }[] = [], hashtags: string[] = []): string { let escaped = escapeHtml(text); for (const mention of mentions) { const at = escapeHtml(`@${mention.acct}`); const url = escapeHtml(mention.url); const localName = mention.acct.split("@")[0]; const span = `@${escapeHtml(localName)}`; escaped = escaped.replaceAll(at, span); } for (const tag of hashtags) { const pattern = new RegExp(`#${escapeHtml(tag)}\\b`, "g"); escaped = escaped.replace(pattern, ``); } return `

${escaped.replace(/\n{2,}/g, "

").replace(/\n/g, "
")}

`; } export function normalizeArray(value: unknown): string[] { if (Array.isArray(value)) return value.map(String); if (typeof value === "string" && value) return [value]; return []; } export function clampLimit(value: unknown, fallback: number, max: number): number { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; return Math.min(Math.floor(parsed), max); } export function baseUrl(env: Env): string { return env.PUBLIC_BASE_URL.replace(/\/+$/, ""); } export function hostFromBaseUrl(env: Env): string { return new URL(baseUrl(env)).host; } export function actorUrl(env: Env, user: User): string { return `${baseUrl(env)}/users/${user.username}`; } export function objectUrl(env: Env, statusId: string): string { return `${baseUrl(env)}/objects/${statusId}`; } export function activityUrl(env: Env, activityId: string): string { return `${baseUrl(env)}/activities/${activityId}`; } export function mediaCdnBaseUrl(env: Env): string | null { const value = (env.MEDIA_BASE_URL ?? "").trim(); if (!value) return null; return value.replace(/\/+$/, ""); } export function mediaUrl(env: Env, r2Key: string): string { const cdn = mediaCdnBaseUrl(env); if (cdn) return `${cdn}/${r2Key.split("/").map(encodeURIComponent).join("/")}`; return `${baseUrl(env)}/media/${encodeURIComponent(r2Key)}`; } export function isLocalActor(env: Env, actorId: string): boolean { try { return new URL(actorId).host === hostFromBaseUrl(env); } catch { return false; } } export function parseAcctFromActor(env: Env, actorId: string): string { try { const url = new URL(actorId); const name = url.pathname.split("/").filter(Boolean).pop() ?? actorId; if (url.host === hostFromBaseUrl(env)) return name; return `${name}@${url.host}`; } catch { return actorId; } }