mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
make view activity works!
This commit is contained in:
@@ -242,6 +242,7 @@ export default function LinkTable({
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
@@ -266,7 +267,7 @@ export default function LinkTable({
|
||||
) : null}
|
||||
<td className="col-alias">
|
||||
<a
|
||||
href={link.linkType === 'redirect' ? (safeUrl ?? `/${link.alias}`) : `/${link.alias}`}
|
||||
href={shortLinkHref}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="alias-pill"
|
||||
@@ -282,7 +283,7 @@ export default function LinkTable({
|
||||
{link.linkType === 'redirect' ? (
|
||||
link.targetUrl ? (
|
||||
safeUrl
|
||||
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate" onClick={stopRowNavigation}>{link.targetUrl}</a>
|
||||
? <span className="truncate" title={link.targetUrl}>{link.targetUrl}</span>
|
||||
: <span className="muted" title="Target URL is not a valid http(s) link">Invalid target</span>
|
||||
) : <span className="muted">—</span>
|
||||
) : <span className="muted">markdown</span>}
|
||||
@@ -331,6 +332,7 @@ export default function LinkTable({
|
||||
<div className="link-cards-grid">
|
||||
{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 (
|
||||
<div
|
||||
@@ -353,7 +355,7 @@ export default function LinkTable({
|
||||
/>
|
||||
) : null}
|
||||
<a
|
||||
href={link.linkType === 'redirect' ? (safeUrl ?? `/${link.alias}`) : `/${link.alias}`}
|
||||
href={shortLinkHref}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="alias-pill"
|
||||
@@ -368,7 +370,7 @@ export default function LinkTable({
|
||||
{link.linkType === 'redirect' && link.targetUrl ? (
|
||||
<p className="link-card-url">
|
||||
{safeUrl
|
||||
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate" onClick={stopRowNavigation}>{link.targetUrl}</a>
|
||||
? <span className="truncate" title={link.targetUrl}>{link.targetUrl}</span>
|
||||
: <span className="muted truncate">{link.targetUrl}</span>}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -74,6 +74,49 @@ class FakeD1Database {
|
||||
click_count: row.click_count,
|
||||
};
|
||||
}
|
||||
|
||||
findPrivateActiveById(ownerUserId: string, id: 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.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<LinkRow, 'scope' | 'status'> | 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');
|
||||
|
||||
+50
-4
@@ -50,6 +50,25 @@ class FakeD1Database {
|
||||
click_count: row.click_count,
|
||||
};
|
||||
}
|
||||
|
||||
findPublicActiveById(id: string): Omit<LinkRow, 'scope' | 'status'> | 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<T>(): Promise<T | null> {
|
||||
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<D1Result> {
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<Response | null> {
|
||||
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<Response> {
|
||||
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.
|
||||
|
||||
@@ -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<ExecutionContext, 'waitUntil'>,
|
||||
): Promise<Response | null> {
|
||||
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<LinkRow>();
|
||||
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<LinkRow>();
|
||||
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<ExecutionContext, 'waitUntil'>,
|
||||
): Promise<Response> {
|
||||
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<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 link resolution.
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user