140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
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 = `<span class="h-card"><a href="${url}" class="u-url mention">@<span>${escapeHtml(localName)}</span></a></span>`;
|
|
escaped = escaped.replaceAll(at, span);
|
|
}
|
|
for (const tag of hashtags) {
|
|
const pattern = new RegExp(`#${escapeHtml(tag)}\\b`, "g");
|
|
escaped = escaped.replace(pattern, `<a href="#" class="mention hashtag" rel="tag">#<span>${escapeHtml(tag)}</span></a>`);
|
|
}
|
|
return `<p>${escaped.replace(/\n{2,}/g, "</p><p>").replace(/\n/g, "<br>")}</p>`;
|
|
}
|
|
|
|
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 profileUrl(env: Env, user: User): string {
|
|
return `${baseUrl(env)}/@${encodeURIComponent(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;
|
|
}
|
|
}
|