mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
feat: resolve private shortlink redirects
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import worker from '../worker/index';
|
||||
import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth';
|
||||
|
||||
type LinkRow = {
|
||||
id: string;
|
||||
alias: string;
|
||||
scope: 'public' | 'private';
|
||||
status: 'active' | 'archived' | 'deleted';
|
||||
link_type: 'redirect' | 'custom';
|
||||
target_url: string | null;
|
||||
content_markdown: string | null;
|
||||
click_count: number;
|
||||
owner_user_id?: string;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image_url: string | null;
|
||||
role: 'user' | 'admin';
|
||||
expires_at: string;
|
||||
session_token_hash: string;
|
||||
};
|
||||
|
||||
type RunCall = {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
class FakeD1Database {
|
||||
readonly preparedSql: string[] = [];
|
||||
readonly runCalls: RunCall[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly links: LinkRow[],
|
||||
private readonly sessions: SessionRow[],
|
||||
) {}
|
||||
|
||||
prepare(sql: string): FakeD1PreparedStatement {
|
||||
this.preparedSql.push(sql);
|
||||
return new FakeD1PreparedStatement(this, sql);
|
||||
}
|
||||
|
||||
findSession(hash: string): SessionRow | null {
|
||||
return this.sessions.find((candidate) => candidate.session_token_hash === hash) ?? null;
|
||||
}
|
||||
|
||||
findPrivateActive(ownerUserId: string, alias: string): Omit<LinkRow, 'scope' | 'status' | 'owner_user_id'> | null {
|
||||
const row = this.links.find((candidate) => {
|
||||
return (
|
||||
candidate.scope === 'private' &&
|
||||
candidate.status === 'active' &&
|
||||
candidate.owner_user_id === ownerUserId &&
|
||||
candidate.alias === alias
|
||||
);
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
alias: row.alias,
|
||||
link_type: row.link_type,
|
||||
target_url: row.target_url,
|
||||
content_markdown: row.content_markdown,
|
||||
click_count: row.click_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FakeD1PreparedStatement {
|
||||
private params: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly db: FakeD1Database,
|
||||
private readonly sql: string,
|
||||
params: unknown[] = [],
|
||||
) {
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
bind(...params: unknown[]): FakeD1PreparedStatement {
|
||||
return new FakeD1PreparedStatement(this.db, this.sql, params);
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
if (this.sql.includes('session_token_hash')) {
|
||||
const hash = String(this.params[0]);
|
||||
const row = this.db.findSession(hash);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
image_url: row.image_url,
|
||||
role: row.role,
|
||||
expires_at: row.expires_at,
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (this.sql.includes("scope='private'")) {
|
||||
expect(this.sql).toContain("status='active'");
|
||||
expect(this.sql).toContain('owner_user_id=?');
|
||||
expect(this.sql).toContain('alias=?');
|
||||
expect(this.sql).toContain('LIMIT 1');
|
||||
|
||||
const ownerUserId = String(this.params[0]);
|
||||
const alias = String(this.params[1]);
|
||||
return this.db.findPrivateActive(ownerUserId, alias) as T;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async run(): Promise<D1Result> {
|
||||
this.db.runCalls.push({ sql: this.sql, params: this.params });
|
||||
return { success: true, meta: {} } as D1Result;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeExecutionContext {
|
||||
readonly promises: Promise<unknown>[] = [];
|
||||
|
||||
waitUntil(promise: Promise<unknown>): void {
|
||||
this.promises.push(promise);
|
||||
}
|
||||
|
||||
passThroughOnException(): void {}
|
||||
}
|
||||
|
||||
function futureIso(): string {
|
||||
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
type EnvBundle = {
|
||||
env: { DB: D1Database };
|
||||
db: FakeD1Database;
|
||||
ctx: FakeExecutionContext;
|
||||
};
|
||||
|
||||
function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = []): EnvBundle {
|
||||
const db = new FakeD1Database(links, sessions);
|
||||
return {
|
||||
env: { DB: db as unknown as D1Database },
|
||||
db,
|
||||
ctx: new FakeExecutionContext(),
|
||||
};
|
||||
}
|
||||
|
||||
async function sessionCookie(token: string): Promise<string> {
|
||||
return `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
||||
}
|
||||
|
||||
async function fetchWorker(
|
||||
url: string,
|
||||
opts: { links?: LinkRow[]; sessions?: SessionRow[]; cookie?: string } = {},
|
||||
) {
|
||||
const { env, db, ctx } = makeEnv(opts.links ?? [], opts.sessions ?? []);
|
||||
const headers = new Headers();
|
||||
if (opts.cookie) {
|
||||
headers.set('cookie', opts.cookie);
|
||||
}
|
||||
const response = await worker.fetch(
|
||||
new Request(url, { headers }) as unknown as Parameters<typeof worker.fetch>[0],
|
||||
env as unknown as Parameters<typeof worker.fetch>[1],
|
||||
ctx as unknown as Parameters<typeof worker.fetch>[2],
|
||||
);
|
||||
return { response, db, ctx };
|
||||
}
|
||||
|
||||
async function userSession(token: string, userId: string): Promise<SessionRow> {
|
||||
const hash = await hashSessionToken(token);
|
||||
return {
|
||||
id: userId,
|
||||
email: `${userId}@heygo.cc`,
|
||||
name: userId,
|
||||
image_url: null,
|
||||
role: 'user',
|
||||
expires_at: futureIso(),
|
||||
session_token_hash: hash,
|
||||
};
|
||||
}
|
||||
|
||||
describe('my.heygo.cc private shortlinks', () => {
|
||||
it('returns 404 with a login link when unauthenticated', async () => {
|
||||
const { response, db } = await fetchWorker('https://my.heygo.cc/foo', {
|
||||
links: [
|
||||
{
|
||||
id: 'link_1',
|
||||
alias: 'foo',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/foo',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
const body = await response.text();
|
||||
expect(body).toContain('Login to use your private links');
|
||||
expect(body).toContain('https://heygo.cc/app/login');
|
||||
// No private alias lookup should happen without a session.
|
||||
expect(db.preparedSql.filter((sql) => sql.includes("scope='private'"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolves the authenticated user private redirect', async () => {
|
||||
const cookie = await sessionCookie('token-a');
|
||||
const session = await userSession('token-a', 'userA');
|
||||
const { response, ctx, db } = await fetchWorker('https://my.heygo.cc/foo', {
|
||||
cookie,
|
||||
sessions: [session],
|
||||
links: [
|
||||
{
|
||||
id: 'link_a',
|
||||
alias: 'foo',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/foo-a',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toBe('https://example.com/foo-a');
|
||||
expect(ctx.promises).toHaveLength(1);
|
||||
await Promise.all(ctx.promises);
|
||||
expect(db.runCalls).toHaveLength(1);
|
||||
expect(db.runCalls[0].sql).toContain('click_count = click_count + 1');
|
||||
expect(db.runCalls[0].params).toEqual(['link_a']);
|
||||
});
|
||||
|
||||
it('isolates private aliases per user (user B resolves own link, not user A)', async () => {
|
||||
const cookieA = await sessionCookie('token-a');
|
||||
const cookieB = await sessionCookie('token-b');
|
||||
const sessionA = await userSession('token-a', 'userA');
|
||||
const sessionB = await userSession('token-b', 'userB');
|
||||
|
||||
const links: LinkRow[] = [
|
||||
{
|
||||
id: 'link_a',
|
||||
alias: 'shared',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/a-shared',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
{
|
||||
id: 'link_b',
|
||||
alias: 'shared',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/b-shared',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userB',
|
||||
},
|
||||
];
|
||||
|
||||
const resA = await fetchWorker('https://my.heygo.cc/shared', {
|
||||
cookie: cookieA,
|
||||
sessions: [sessionA, sessionB],
|
||||
links,
|
||||
});
|
||||
expect(resA.response.status).toBe(302);
|
||||
expect(resA.response.headers.get('location')).toBe('https://example.com/a-shared');
|
||||
|
||||
const resB = await fetchWorker('https://my.heygo.cc/shared', {
|
||||
cookie: cookieB,
|
||||
sessions: [sessionA, sessionB],
|
||||
links,
|
||||
});
|
||||
expect(resB.response.status).toBe(302);
|
||||
expect(resB.response.headers.get('location')).toBe('https://example.com/b-shared');
|
||||
});
|
||||
|
||||
it('returns 404 for an authenticated user who does not own the alias (no public fallback)', async () => {
|
||||
const cookie = await sessionCookie('token-b');
|
||||
const sessionB = await userSession('token-b', 'userB');
|
||||
const { response, db } = await fetchWorker('https://my.heygo.cc/only-public', {
|
||||
cookie,
|
||||
sessions: [sessionB],
|
||||
links: [
|
||||
{
|
||||
id: 'public_link',
|
||||
alias: 'only-public',
|
||||
scope: 'public',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/public',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'link_a',
|
||||
alias: 'only-public',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/a-only',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.text();
|
||||
expect(body).toContain('Create this private link');
|
||||
// Must never query the public scope on my.heygo.cc.
|
||||
expect(db.preparedSql.filter((sql) => sql.includes("scope='public'"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders an authenticated private custom link as escaped HTML', async () => {
|
||||
const cookie = await sessionCookie('token-a');
|
||||
const session = await userSession('token-a', 'userA');
|
||||
const { response } = await fetchWorker('https://my.heygo.cc/note', {
|
||||
cookie,
|
||||
sessions: [session],
|
||||
links: [
|
||||
{
|
||||
id: 'link_custom',
|
||||
alias: 'note',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'custom',
|
||||
target_url: null,
|
||||
content_markdown: '# Private Note\n<script>alert("x")</script>',
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
const html = await response.text();
|
||||
expect(html).toContain('Private Note');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('returns 404 with Create this private link when the alias is missing for the user', async () => {
|
||||
const cookie = await sessionCookie('token-a');
|
||||
const session = await userSession('token-a', 'userA');
|
||||
const { response } = await fetchWorker('https://my.heygo.cc/does-not-exist', {
|
||||
cookie,
|
||||
sessions: [session],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
const body = await response.text();
|
||||
expect(body).toContain('Create this private link');
|
||||
});
|
||||
|
||||
it('handles the root path: unauthenticated 404 + login, authenticated 302 to app/private', async () => {
|
||||
const unauth = await fetchWorker('https://my.heygo.cc/');
|
||||
expect(unauth.response.status).toBe(404);
|
||||
expect(await unauth.response.text()).toContain('Login to use your private links');
|
||||
|
||||
const cookie = await sessionCookie('token-a');
|
||||
const session = await userSession('token-a', 'userA');
|
||||
const auth = await fetchWorker('https://my.heygo.cc/', {
|
||||
cookie,
|
||||
sessions: [session],
|
||||
});
|
||||
expect(auth.response.status).toBe(302);
|
||||
expect(auth.response.headers.get('location')).toBe('https://heygo.cc/app/private');
|
||||
});
|
||||
|
||||
it('does not trigger D1 private alias lookup for reserved paths', async () => {
|
||||
const cookie = await sessionCookie('token-a');
|
||||
const session = await userSession('token-a', 'userA');
|
||||
const links: LinkRow[] = [
|
||||
{
|
||||
id: 'link_api',
|
||||
alias: 'api',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/api',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
{
|
||||
id: 'link_app',
|
||||
alias: 'app',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/app',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
{
|
||||
id: 'link_admin',
|
||||
alias: 'admin',
|
||||
scope: 'private',
|
||||
status: 'active',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/admin',
|
||||
content_markdown: null,
|
||||
click_count: 0,
|
||||
owner_user_id: 'userA',
|
||||
},
|
||||
];
|
||||
|
||||
for (const path of ['/api/foo', '/app/foo', '/admin/foo']) {
|
||||
const { db } = await fetchWorker(`https://my.heygo.cc${path}`, {
|
||||
cookie,
|
||||
sessions: [session],
|
||||
links,
|
||||
});
|
||||
expect(db.preparedSql.filter((sql) => sql.includes("scope='private'"))).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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, '&')
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user