Files
Toot-Worker/src/util.ts
T
2026-05-16 09:20:00 +08:00

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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;
}
}