Files
heygo/worker/routes/redirect.ts
T
2026-06-20 16:10:20 +10:00

124 lines
3.6 KiB
TypeScript

import type { Env } from '../env';
import { validateAlias } from '../lib/aliases';
import { renderCustomLinkHtml } from '../lib/custom-link';
import { htmlResponse, publicBadRequestResponse, publicNotFoundResponse } from '../lib/responses';
import { resolveTemplateUrl, TemplateResolutionError } from '../lib/templates';
type PublicLinkRow = {
id: string;
alias: string;
link_type: 'redirect' | 'custom';
target_url: string | null;
content_markdown: string | null;
click_count: number;
};
const PUBLIC_LINK_QUERY = `SELECT id, alias, link_type, target_url, content_markdown, click_count
FROM links
WHERE scope='public' AND status='active' AND alias=?
LIMIT 1`;
const CLICK_COUNT_UPDATE = `UPDATE links SET click_count = click_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id=?`;
const CLICK_DAILY_UPSERT = `INSERT INTO click_daily (link_id, day, count) VALUES (?, strftime('%Y-%m-%d', 'now'), 1)
ON CONFLICT (link_id, day) DO UPDATE SET count = count + 1`;
export function isReservedPublicPath(pathname: string): boolean {
return pathname === '/api' || pathname.startsWith('/api/')
|| pathname === '/app' || pathname.startsWith('/app/')
|| pathname === '/admin' || pathname.startsWith('/admin/');
}
export function isHeygoPublicHost(hostname: string, publicHost: string): boolean {
if (publicHost === '*') return true; // local dev: match any host (LAN access)
return hostname === publicHost;
}
export async function handlePublicShortlink(
request: Request,
env: Env,
ctx: Pick<ExecutionContext, 'waitUntil'>,
): Promise<Response> {
const url = new URL(request.url);
const pathParts = parseAliasPath(url.pathname);
if (!pathParts) {
return publicNotFoundResponse();
}
const aliasValidation = validateAlias(pathParts.alias);
if (!aliasValidation.ok || aliasValidation.value !== pathParts.alias.toLowerCase()) {
return publicNotFoundResponse();
}
const link = await env.DB.prepare(PUBLIC_LINK_QUERY).bind(aliasValidation.value).first<PublicLinkRow>();
if (!link) {
return publicNotFoundResponse();
}
ctx.waitUntil(recordClick(env.DB, link.id));
if (link.link_type === 'redirect') {
if (!link.target_url) {
return publicNotFoundResponse();
}
try {
const location = resolveTemplateUrl({
targetUrl: link.target_url,
pathParam: pathParts.pathParam,
query: url.searchParams,
});
return Response.redirect(location, 302);
} catch (error) {
if (error instanceof TemplateResolutionError) {
return publicBadRequestResponse(error.message);
}
throw error;
}
}
if (link.link_type === 'custom') {
return htmlResponse(renderCustomLinkHtml(link), { status: 200 });
}
return publicNotFoundResponse();
}
type ParsedAliasPath = {
alias: string;
pathParam?: string;
};
export function parseAliasPath(pathname: string): ParsedAliasPath | null {
const rawSegments = pathname.split('/').slice(1);
if (rawSegments.length === 0 || rawSegments.length > 2 || rawSegments[0] === '') {
return null;
}
if (rawSegments.length === 2 && rawSegments[1] === '') {
return null;
}
try {
const alias = decodeURIComponent(rawSegments[0]);
const pathParam = rawSegments[1] === undefined ? undefined : decodeURIComponent(rawSegments[1]);
return { alias, pathParam };
} catch {
return null;
}
}
async function recordClick(db: D1Database, linkId: string): Promise<void> {
try {
await db.batch([
db.prepare(CLICK_COUNT_UPDATE).bind(linkId),
db.prepare(CLICK_DAILY_UPSERT).bind(linkId),
]);
} catch {
// Analytics must never block or break a public redirect/render.
}
}