diff --git a/src/components/LinkTable.tsx b/src/components/LinkTable.tsx index f81762e..832b9f1 100644 --- a/src/components/LinkTable.tsx +++ b/src/components/LinkTable.tsx @@ -242,6 +242,7 @@ export default function LinkTable({ {visible.map((link) => { const selected = selectable && selectedIds?.has(link.id) === true; + const shortLinkHref = `/links/${encodeURIComponent(link.id)}/go`; const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; return ( {link.targetUrl} + ? {link.targetUrl} : Invalid target ) : ) : markdown} @@ -331,6 +332,7 @@ export default function LinkTable({
{visible.map((link) => { const selected = selectable && selectedIds?.has(link.id) === true; + const shortLinkHref = `/links/${encodeURIComponent(link.id)}/go`; const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; return (
) : null} {safeUrl - ? {link.targetUrl} + ? {link.targetUrl} : {link.targetUrl}}

) : null} diff --git a/tests/private-redirect.test.ts b/tests/private-redirect.test.ts index 5e1d810..16690cb 100644 --- a/tests/private-redirect.test.ts +++ b/tests/private-redirect.test.ts @@ -74,6 +74,49 @@ class FakeD1Database { click_count: row.click_count, }; } + + findPrivateActiveById(ownerUserId: string, id: string): Omit | null { + const row = this.links.find((candidate) => { + return ( + candidate.scope === 'private' && + candidate.status === 'active' && + candidate.owner_user_id === ownerUserId && + candidate.id === id + ); + }); + + 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, + }; + } + + findPublicActiveById(id: string): Omit | null { + const row = this.links.find((candidate) => { + return candidate.scope === 'public' && candidate.status === 'active' && candidate.id === id; + }); + + 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 { @@ -111,12 +154,25 @@ class FakeD1PreparedStatement { 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; + if (this.sql.includes('alias=?')) { + const ownerUserId = String(this.params[0]); + const alias = String(this.params[1]); + return this.db.findPrivateActive(ownerUserId, alias) as T; + } + + expect(this.sql).toContain('id=?'); + const id = String(this.params[0]); + const ownerUserId = String(this.params[1]); + return this.db.findPrivateActiveById(ownerUserId, id) as T; + } + + if (this.sql.includes("scope='public'")) { + expect(this.sql).toContain("status='active'"); + expect(this.sql).toContain('id=?'); + const id = String(this.params[0]); + return this.db.findPublicActiveById(id) as T; } return null; @@ -284,6 +340,36 @@ describe('my.heygo.cc private shortlinks', () => { expect(db.runCalls[1].params).toEqual(['link_a']); }); + it('resolves /links/:id/go for the authenticated owner from the app host', async () => { + const cookie = await sessionCookie('token-a'); + const session = await userSession('token-a', 'userA'); + const { response, ctx, db } = await fetchWorker('https://heygo.cc/links/link_a/go', { + 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'); + expectPrivateNoStoreHeaders(response); + expect(ctx.promises).toHaveLength(1); + await Promise.all(ctx.promises); + expect(db.runCalls[0].params).toEqual(['link_a']); + expect(db.runCalls[1].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'); diff --git a/tests/redirect.test.ts b/tests/redirect.test.ts index d012b7b..163354a 100644 --- a/tests/redirect.test.ts +++ b/tests/redirect.test.ts @@ -50,6 +50,25 @@ class FakeD1Database { click_count: row.click_count, }; } + + findPublicActiveById(id: string): Omit | null { + const row = this.rows.find((candidate) => { + return candidate.id === id && candidate.scope === 'public' && candidate.status === 'active'; + }); + + 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 { @@ -66,10 +85,13 @@ class FakeD1PreparedStatement { async first(): Promise { expect(this.sql).toContain("scope='public'"); expect(this.sql).toContain("status='active'"); - expect(this.sql).toContain('alias=?'); - - const alias = String(this.params[0]); - return this.db.findPublicActive(alias) as T | null; + if (this.sql.includes('alias=?')) { + const alias = String(this.params[0]); + return this.db.findPublicActive(alias) as T | null; + } + expect(this.sql).toContain('id=?'); + const id = String(this.params[0]); + return this.db.findPublicActiveById(id) as T | null; } async run(): Promise { @@ -164,6 +186,30 @@ describe('public heygo.cc shortlink redirects', () => { expect(html).toContain('<script>'); }); + it('resolves /links/:id/go for a public link and records analytics', async () => { + const { response, ctx, db } = await fetchWorker('https://heygo.cc/links/link_2/go', [ + { + id: 'link_2', + alias: 'about', + scope: 'public', + status: 'active', + link_type: 'custom', + target_url: null, + content_markdown: '# About Heygo', + click_count: 5, + }, + ]); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(ctx.promises).toHaveLength(1); + + await Promise.all(ctx.promises); + expect(db.runCalls).toHaveLength(2); + expect(db.runCalls[0].params).toEqual(['link_2']); + expect(db.runCalls[1].params).toEqual(['link_2']); + }); + it('returns a public 404 HTML response for an unknown alias on heygo.cc', async () => { const { response } = await fetchWorker('https://heygo.cc/missing'); diff --git a/worker/index.ts b/worker/index.ts index c570676..02a6751 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -3,6 +3,7 @@ import { withPrivateNoStoreHeaders } from './lib/responses'; import { handleAuthApi } from './routes/api.auth'; import { handleDevAuth } from './routes/api.dev-auth'; import { handleLinksApi } from './routes/api.links'; +import { handleLinkGoRoute } from './routes/link-go'; import { handlePromotionsApi } from './routes/api.promotions'; import { handlePrivateShortlink, isHeygoPrivateHost } from './routes/private-redirect'; import { handlePublicShortlink, isHeygoPublicHost, isReservedPublicPath } from './routes/redirect'; @@ -27,10 +28,74 @@ function withPrivateHostNoStoreHeaders(url: URL, response: Response, privateHost return isHeygoPrivateHost(url.hostname, privateHost) ? withPrivateNoStoreHeaders(response) : response; } +function isHtmlNavigationRequest(request: Request): boolean { + const accept = request.headers.get('accept') ?? ''; + const secFetchDest = request.headers.get('sec-fetch-dest') ?? ''; + return accept.includes('text/html') || secFetchDest === 'document'; +} + +function spaHashUrl(appBaseUrl: string, route: string): string { + return `${appBaseUrl}/#${route}`; +} + +function spaRedirectForAppPath(url: URL, appBaseUrl: string): Response | null { + const pathname = url.pathname.replace(/\/+$/, '') || '/'; + if (pathname === '/app') { + return Response.redirect(spaHashUrl(appBaseUrl, '/'), 302); + } + if (pathname === '/app/private') { + return Response.redirect(spaHashUrl(appBaseUrl, '/my-links'), 302); + } + if (pathname === '/app/login') { + return Response.redirect(spaHashUrl(appBaseUrl, '/login'), 302); + } + if (pathname === '/app/dev-login') { + return Response.redirect(spaHashUrl(appBaseUrl, '/dev-login'), 302); + } + if (pathname === '/app/admin') { + return Response.redirect(spaHashUrl(appBaseUrl, '/admin'), 302); + } + if (pathname === '/app/profile') { + return Response.redirect(spaHashUrl(appBaseUrl, '/profile'), 302); + } + if (pathname === '/app/settings') { + return Response.redirect(spaHashUrl(appBaseUrl, '/settings'), 302); + } + return null; +} + +async function serveSpaShell(request: Request, env: Env): Promise { + if (!env.ASSETS) { + return null; + } + + const url = new URL(request.url); + if (url.pathname.startsWith('/assets/')) { + return env.ASSETS.fetch(request); + } + + if (url.pathname !== '/' || !isHtmlNavigationRequest(request)) { + return null; + } + + const assetUrl = new URL('/index.html', url); + return env.ASSETS.fetch(new Request(assetUrl, request)); +} + export default { async fetch(request, env, ctx): Promise { const url = new URL(request.url); + const appPathRedirect = spaRedirectForAppPath(url, env.APP_BASE_URL); + if (appPathRedirect) { + return withPrivateHostNoStoreHeaders(url, appPathRedirect, env.PRIVATE_HOST); + } + + const spaShellResponse = await serveSpaShell(request, env); + if (spaShellResponse && isHeygoPublicHost(url.hostname, env.PUBLIC_HOST)) { + return withPrivateHostNoStoreHeaders(url, spaShellResponse, env.PRIVATE_HOST); + } + if (url.pathname === '/api/health') { return withPrivateHostNoStoreHeaders(url, json({ ok: true, service: 'heygo-worker' }), env.PRIVATE_HOST); } @@ -55,6 +120,11 @@ export default { return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }), env.PRIVATE_HOST); } + const linkGoResponse = await handleLinkGoRoute(request, env, ctx); + if (linkGoResponse) { + return withPrivateHostNoStoreHeaders(url, linkGoResponse, env.PRIVATE_HOST); + } + // In local dev (PUBLIC_HOST=*), public and private share the same host. // Try public first; if it returns 404 (alias not found), fall through to // private so authenticated users can resolve their personal shortlinks. diff --git a/worker/routes/link-go.ts b/worker/routes/link-go.ts new file mode 100644 index 0000000..876a8f2 --- /dev/null +++ b/worker/routes/link-go.ts @@ -0,0 +1,131 @@ +import { getCurrentUser } from '../auth'; +import type { Env } from '../env'; +import { renderCustomLinkHtml } from '../lib/custom-link'; +import { + htmlResponse, + privateAliasNotFoundResponse, + privateHtmlResponse, + privateLoginRequiredResponse, + privateRedirectResponse, + publicBadRequestResponse, + publicNotFoundResponse, + withPrivateNoStoreHeaders, +} from '../lib/responses'; +import { resolveTemplateUrl, TemplateResolutionError } from '../lib/templates'; + +type LinkRow = { + id: string; + alias: string; + link_type: 'redirect' | 'custom'; + target_url: string | null; + content_markdown: string | null; +}; + +const PUBLIC_LINK_BY_ID_QUERY = `SELECT id, alias, link_type, target_url, content_markdown +FROM links +WHERE id=? AND scope='public' AND status='active' +LIMIT 1`; + +const PRIVATE_LINK_BY_ID_QUERY = `SELECT id, alias, link_type, target_url, content_markdown +FROM links +WHERE id=? AND scope='private' AND owner_user_id=? AND status='active' +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 async function handleLinkGoRoute( + request: Request, + env: Env, + ctx: Pick, +): Promise { + const url = new URL(request.url); + const match = url.pathname.match(/^\/links\/([^/]+)\/go$/); + if (!match) { + return null; + } + + const linkId = decodePathSegment(match[1]); + if (!linkId) { + return publicNotFoundResponse(); + } + + const publicLink = await env.DB.prepare(PUBLIC_LINK_BY_ID_QUERY).bind(linkId).first(); + if (publicLink) { + return resolveGoResponse(publicLink, false, request, env, ctx); + } + + const user = await getCurrentUser(request, env); + if (!user) { + return privateLoginRequiredResponse(env.APP_BASE_URL); + } + + const privateLink = await env.DB.prepare(PRIVATE_LINK_BY_ID_QUERY).bind(linkId, user.id).first(); + if (!privateLink) { + return privateAliasNotFoundResponse(env.APP_BASE_URL); + } + + return resolveGoResponse(privateLink, true, request, env, ctx); +} + +async function resolveGoResponse( + link: LinkRow, + isPrivate: boolean, + request: Request, + env: Env, + ctx: Pick, +): Promise { + const url = new URL(request.url); + + ctx.waitUntil(recordClick(env.DB, link.id)); + + if (link.link_type === 'redirect') { + if (!link.target_url) { + return isPrivate ? privateAliasNotFoundResponse(env.APP_BASE_URL) : publicNotFoundResponse(); + } + + try { + const location = resolveTemplateUrl({ + targetUrl: link.target_url, + query: url.searchParams, + }); + + return isPrivate ? privateRedirectResponse(location, 302) : Response.redirect(location, 302); + } catch (error) { + if (error instanceof TemplateResolutionError) { + return isPrivate + ? withPrivateNoStoreHeaders(publicBadRequestResponse(error.message)) + : publicBadRequestResponse(error.message); + } + + throw error; + } + } + + if (link.link_type === 'custom') { + const html = renderCustomLinkHtml(link); + return isPrivate ? privateHtmlResponse(html, { status: 200 }) : htmlResponse(html, { status: 200 }); + } + + return isPrivate ? privateAliasNotFoundResponse(env.APP_BASE_URL) : publicNotFoundResponse(); +} + +function decodePathSegment(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +async function recordClick(db: D1Database, linkId: string): Promise { + 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 link resolution. + } +} diff --git a/wrangler.dev.jsonc b/wrangler.dev.jsonc index fb82cae..956a13c 100644 --- a/wrangler.dev.jsonc +++ b/wrangler.dev.jsonc @@ -4,8 +4,7 @@ "main": "worker/index.ts", "compatibility_date": "2026-06-20", "assets": { - "directory": "./dist/client", - "not_found_handling": "single-page-application" + "directory": "./dist/client" }, "observability": { "enabled": true diff --git a/wrangler.jsonc b/wrangler.jsonc index ea5af15..62cb316 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -4,8 +4,7 @@ "main": "worker/index.ts", "compatibility_date": "2026-06-20", "assets": { - "directory": "./dist/client", - "not_found_handling": "single-page-application" + "directory": "./dist/client" }, "observability": { "enabled": true diff --git a/wrangler.prod.jsonc b/wrangler.prod.jsonc index c5bebf7..ecccd17 100644 --- a/wrangler.prod.jsonc +++ b/wrangler.prod.jsonc @@ -4,8 +4,7 @@ "main": "worker/index.ts", "compatibility_date": "2026-06-20", "assets": { - "directory": "./dist/client", - "not_found_handling": "single-page-application" + "directory": "./dist/client" }, "observability": { "enabled": true