feat: resolve private shortlink redirects

This commit is contained in:
Hermes Agent
2026-06-20 11:12:21 +10:00
parent 10158e1f51
commit 0af13191df
6 changed files with 632 additions and 46 deletions
+5
View File
@@ -1,4 +1,5 @@
import type { Env } from './env';
import { handlePrivateShortlink, isHeygoPrivateHost } from './routes/private-redirect';
import { handlePublicShortlink, isHeygoPublicHost, isReservedPublicPath } from './routes/redirect';
export type { Env } from './env';
@@ -33,6 +34,10 @@ export default {
return handlePublicShortlink(request, env, ctx);
}
if (isHeygoPrivateHost(url) && !isReservedPublicPath(url.pathname)) {
return handlePrivateShortlink(request, env, ctx);
}
return json({ error: 'Not found' }, { status: 404 });
},
} satisfies ExportedHandler<Env>;
+49
View File
@@ -0,0 +1,49 @@
import { escapeHtml } from './responses';
export type CustomLinkLike = {
alias: string;
content_markdown: string | null;
};
export function renderCustomLinkHtml(link: CustomLinkLike): string {
const content = renderMarkdown(link.content_markdown ?? '');
const title = escapeHtml(link.alias);
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title></head><body><main>${content}</main></body></html>`;
}
function renderMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const rendered: string[] = [];
let paragraph: string[] = [];
const flushParagraph = () => {
if (paragraph.length > 0) {
rendered.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
};
for (const line of lines) {
const escaped = escapeHtml(line);
const heading = escaped.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
flushParagraph();
const level = heading[1].length;
rendered.push(`<h${level}>${heading[2]}</h${level}>`);
continue;
}
if (escaped.trim() === '') {
flushParagraph();
continue;
}
paragraph.push(escaped);
}
flushParagraph();
return rendered.join('\n');
}
+17
View File
@@ -22,6 +22,23 @@ export function publicBadRequestResponse(message = 'Invalid short link'): Respon
);
}
export const PRIVATE_LOGIN_URL = 'https://heygo.cc/app/login';
export const PRIVATE_APP_URL = 'https://heygo.cc/app/private';
export function privateLoginRequiredResponse(): Response {
return htmlResponse(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login required</title></head><body><h1>Not found</h1><p><a href="${PRIVATE_LOGIN_URL}">Login to use your private links</a>.</p></body></html>`,
{ status: 404 },
);
}
export function privateAliasNotFoundResponse(): Response {
return htmlResponse(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head><body><h1>Not found</h1><p>This private link does not exist.</p><p><a href="${PRIVATE_APP_URL}">Create this private link</a>.</p></body></html>`,
{ status: 404 },
);
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
+118
View File
@@ -0,0 +1,118 @@
import type { Env } from '../env';
import { getCurrentUser } from '../auth';
import { validateAlias } from '../lib/aliases';
import { renderCustomLinkHtml } from '../lib/custom-link';
import {
PRIVATE_APP_URL,
htmlResponse,
privateAliasNotFoundResponse,
privateLoginRequiredResponse,
publicBadRequestResponse,
} from '../lib/responses';
import { resolveTemplateUrl, TemplateResolutionError } from '../lib/templates';
import { parseAliasPath } from './redirect';
type PrivateLinkRow = {
id: string;
alias: string;
link_type: 'redirect' | 'custom';
target_url: string | null;
content_markdown: string | null;
click_count: number;
};
const PRIVATE_LINK_QUERY = `SELECT id, alias, link_type, target_url, content_markdown, click_count
FROM links
WHERE scope='private' AND status='active' AND owner_user_id=? 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=?`;
export function isHeygoPrivateHost(url: URL): boolean {
return url.hostname === 'my.heygo.cc';
}
export async function handlePrivateShortlink(
request: Request,
env: Env,
ctx: Pick<ExecutionContext, 'waitUntil'>,
): Promise<Response> {
const url = new URL(request.url);
// Root: authenticated users go to the private links app; everyone else gets a login prompt.
if (url.pathname === '/') {
const user = await getCurrentUser(request, env);
if (!user) {
return privateLoginRequiredResponse();
}
return Response.redirect(PRIVATE_APP_URL, 302);
}
const pathParts = parseAliasPath(url.pathname);
if (!pathParts) {
return privateNotFoundOrLogin(request, env);
}
const aliasValidation = validateAlias(pathParts.alias);
if (!aliasValidation.ok || aliasValidation.value !== pathParts.alias.toLowerCase()) {
return privateNotFoundOrLogin(request, env);
}
const user = await getCurrentUser(request, env);
if (!user) {
return privateLoginRequiredResponse();
}
const link = await env.DB.prepare(PRIVATE_LINK_QUERY)
.bind(user.id, aliasValidation.value)
.first<PrivateLinkRow>();
if (!link) {
return privateAliasNotFoundResponse();
}
ctx.waitUntil(recordClick(env.DB, link.id));
if (link.link_type === 'redirect') {
if (!link.target_url) {
return privateAliasNotFoundResponse();
}
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 privateAliasNotFoundResponse();
}
async function privateNotFoundOrLogin(request: Request, env: Env): Promise<Response> {
const user = await getCurrentUser(request, env);
if (!user) {
return privateLoginRequiredResponse();
}
return privateAliasNotFoundResponse();
}
async function recordClick(db: D1Database, linkId: string): Promise<void> {
try {
await db.prepare(CLICK_COUNT_UPDATE).bind(linkId).run();
} catch {
// Analytics must never block or break a private redirect/render.
}
}
+4 -46
View File
@@ -1,6 +1,7 @@
import type { Env } from '../env';
import { validateAlias } from '../lib/aliases';
import { escapeHtml, htmlResponse, publicBadRequestResponse, publicNotFoundResponse } from '../lib/responses';
import { renderCustomLinkHtml } from '../lib/custom-link';
import { htmlResponse, publicBadRequestResponse, publicNotFoundResponse } from '../lib/responses';
import { resolveTemplateUrl, TemplateResolutionError } from '../lib/templates';
type PublicLinkRow = {
@@ -76,7 +77,7 @@ export async function handlePublicShortlink(
}
if (link.link_type === 'custom') {
return htmlResponse(renderCustomLink(link), { status: 200 });
return htmlResponse(renderCustomLinkHtml(link), { status: 200 });
}
return publicNotFoundResponse();
@@ -87,7 +88,7 @@ type ParsedAliasPath = {
pathParam?: string;
};
function parseAliasPath(pathname: string): ParsedAliasPath | null {
export function parseAliasPath(pathname: string): ParsedAliasPath | null {
const rawSegments = pathname.split('/').slice(1);
if (rawSegments.length === 0 || rawSegments.length > 2 || rawSegments[0] === '') {
@@ -114,46 +115,3 @@ async function recordClick(db: D1Database, linkId: string): Promise<void> {
// Analytics must never block or break a public redirect/render.
}
}
function renderCustomLink(link: PublicLinkRow): string {
const content = renderMarkdown(link.content_markdown ?? '');
const title = escapeHtml(link.alias);
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title></head><body><main>${content}</main></body></html>`;
}
function renderMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const rendered: string[] = [];
let paragraph: string[] = [];
const flushParagraph = () => {
if (paragraph.length > 0) {
rendered.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
};
for (const line of lines) {
const escaped = escapeHtml(line);
const heading = escaped.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
flushParagraph();
const level = heading[1].length;
rendered.push(`<h${level}>${heading[2]}</h${level}>`);
continue;
}
if (escaped.trim() === '') {
flushParagraph();
continue;
}
paragraph.push(escaped);
}
flushParagraph();
return rendered.join('\n');
}