mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
- LandingHero: service intro + search input for unauthenticated users - App.tsx/routing: logged-in users auto-redirect to My Links - 404 pages: styled with login prompt + alias-based redirect - Worker: getLinkById API for link detail page (LINK_BY_ID_*_QUERY) - Private redirect: pass alias to 404/login pages for redirect flow - Tests: update assertions for new 404 page text
803 lines
26 KiB
TypeScript
803 lines
26 KiB
TypeScript
import { z } from 'zod';
|
|
import { AuthError, requireAdmin, requireUser, getCurrentUser, type AuthUser } from '../auth';
|
|
import type { Env } from '../env';
|
|
import { validateAlias } from '../lib/aliases';
|
|
|
|
const jsonHeaders = {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
};
|
|
|
|
const linkInputSchema = z.object({
|
|
alias: z.string().min(1).max(100),
|
|
linkType: z.enum(['redirect', 'custom']),
|
|
targetUrl: z.union([z.string(), z.null()]).optional(),
|
|
contentMarkdown: z.string().optional(),
|
|
description: z.string().optional(),
|
|
sourcePublicLinkId: z.string().min(1).optional(),
|
|
});
|
|
|
|
type LinkInput = z.infer<typeof linkInputSchema>;
|
|
|
|
type LinkScope = 'public' | 'private';
|
|
type LinkType = 'redirect' | 'custom';
|
|
|
|
type LinkRow = {
|
|
id: string;
|
|
alias: string;
|
|
scope: LinkScope;
|
|
link_type: LinkType;
|
|
target_url: string | null;
|
|
content_markdown: string | null;
|
|
description: string | null;
|
|
owner_user_id: string | null;
|
|
click_count: number;
|
|
status: 'active' | 'archived' | 'deleted';
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type NormalizedLinkInput = {
|
|
alias: string;
|
|
linkType: LinkType;
|
|
targetUrl: string | null;
|
|
contentMarkdown: string | null;
|
|
description: string | null;
|
|
sourcePublicLinkId?: string;
|
|
};
|
|
|
|
const LINK_COLUMNS = `id, alias, scope, link_type, target_url, content_markdown, description, owner_user_id, click_count, status, created_at, updated_at`;
|
|
|
|
const PRIVATE_LINK_LIST_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='private' AND status='active' AND owner_user_id=?
|
|
ORDER BY click_count DESC, updated_at DESC`;
|
|
|
|
const PRIVATE_LINK_SEARCH_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='private' AND status='active' AND owner_user_id=? AND alias LIKE ? ESCAPE '\\'
|
|
ORDER BY CASE WHEN alias = ? COLLATE NOCASE THEN 0 ELSE 1 END, click_count DESC, updated_at DESC`;
|
|
|
|
const PUBLIC_LINK_LIST_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='public' AND status='active'
|
|
ORDER BY click_count DESC, updated_at DESC`;
|
|
|
|
const PUBLIC_LINK_SEARCH_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='public' AND status='active' AND alias LIKE ? ESCAPE '\\'
|
|
ORDER BY CASE WHEN alias = ? COLLATE NOCASE THEN 0 ELSE 1 END, click_count DESC, updated_at DESC`;
|
|
|
|
const LINK_BY_ID_PRIVATE_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE id=? AND scope='private' AND owner_user_id=? AND status='active'
|
|
LIMIT 1`;
|
|
|
|
const LINK_BY_ID_PUBLIC_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE id=? AND scope='public' AND status='active'
|
|
LIMIT 1`;
|
|
|
|
const CLICK_STATS_QUERY = `SELECT day, count FROM click_daily
|
|
WHERE link_id=? AND day >= ?
|
|
ORDER BY day ASC`;
|
|
|
|
const CLICK_STATS_ALL_QUERY = `SELECT day, count FROM click_daily
|
|
WHERE link_id=?
|
|
ORDER BY day ASC`;
|
|
|
|
const CHANGE_LOG_QUERY = `SELECT id, change_type, old_value, new_value, changed_at
|
|
FROM link_change_logs
|
|
WHERE link_id=?
|
|
ORDER BY changed_at DESC
|
|
LIMIT 100`;
|
|
|
|
const CHANGE_LOG_INSERT = `INSERT INTO link_change_logs (id, link_id, change_type, old_value, new_value)
|
|
VALUES (?, ?, ?, ?, ?)`;
|
|
|
|
const PRIVATE_DUPLICATE_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='private' AND status != 'deleted' AND owner_user_id=? AND alias=?
|
|
LIMIT 1`;
|
|
|
|
const PUBLIC_DUPLICATE_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='public' AND status != 'deleted' AND alias=?
|
|
LIMIT 1`;
|
|
|
|
const PRIVATE_DUPLICATE_EXCLUDING_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='private' AND status != 'deleted' AND owner_user_id=? AND alias=? AND id!=?
|
|
LIMIT 1`;
|
|
|
|
const PUBLIC_DUPLICATE_EXCLUDING_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='public' AND status != 'deleted' AND alias=? AND id!=?
|
|
LIMIT 1`;
|
|
|
|
const LINK_INSERT = `INSERT INTO links (id, scope, owner_user_id, alias, link_type, target_url, content_markdown, description, status, click_count)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 0)`;
|
|
|
|
const LINK_UPDATE = `UPDATE links SET alias=?, link_type=?, target_url=?, content_markdown=?, description=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
WHERE id=? AND scope=? AND status='active'`;
|
|
|
|
const PRIVATE_LINK_UPDATE = `UPDATE links SET alias=?, link_type=?, target_url=?, content_markdown=?, description=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
WHERE id=? AND scope='private' AND owner_user_id=? AND status='active'`;
|
|
|
|
const PRIVATE_LINK_DELETE = `UPDATE links SET status='deleted', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
WHERE id=? AND scope='private' AND owner_user_id=? AND status='active'`;
|
|
|
|
const PUBLIC_LINK_DELETE = `UPDATE links SET status='deleted', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
WHERE id=? AND scope='public' AND status='active'`;
|
|
|
|
export async function handleLinksApi(request: Request, env: Env): Promise<Response | null> {
|
|
const url = new URL(request.url);
|
|
const pathname = url.pathname;
|
|
|
|
try {
|
|
if (pathname === '/api/links/private') {
|
|
if (request.method === 'GET') {
|
|
return await listPrivateLinks(request, env);
|
|
}
|
|
if (request.method === 'POST') {
|
|
return await createPrivateLink(request, env);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
if (pathname.match(/^\/api\/links\/private\/[^/]+$/) && request.method === 'GET') {
|
|
const id = decodePathSegment(pathname.split('/').pop()!);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getPrivateLink(request, env, id);
|
|
}
|
|
|
|
const privateLinkStatsMatch = pathname.match(/^\/api\/links\/private\/([^/]+)\/stats$/);
|
|
if (privateLinkStatsMatch && request.method === 'GET') {
|
|
const id = decodePathSegment(privateLinkStatsMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getLinkStats(request, env, id, 'private');
|
|
}
|
|
|
|
const privateLinkHistoryMatch = pathname.match(/^\/api\/links\/private\/([^/]+)\/history$/);
|
|
if (privateLinkHistoryMatch && request.method === 'GET') {
|
|
const id = decodePathSegment(privateLinkHistoryMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getLinkHistory(request, env, id, 'private');
|
|
}
|
|
|
|
const privateLinkMatch = pathname.match(/^\/api\/links\/private\/([^/]+)$/);
|
|
if (privateLinkMatch) {
|
|
const id = decodePathSegment(privateLinkMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid link id' }, { status: 400 });
|
|
}
|
|
if (request.method === 'PATCH') {
|
|
return await updatePrivateLink(request, env, id);
|
|
}
|
|
if (request.method === 'DELETE') {
|
|
return await deletePrivateLink(request, env, id);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
if (pathname === '/api/links/public') {
|
|
if (request.method === 'GET') {
|
|
return await listPublicLinks(request, env);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
if (pathname.match(/^\/api\/links\/public\/[^/]+$/) && request.method === 'GET') {
|
|
const id = decodePathSegment(pathname.split('/').pop()!);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getPublicLink(env, id);
|
|
}
|
|
|
|
const publicReadableStatsMatch = pathname.match(/^\/api\/links\/public\/([^/]+)\/stats$/);
|
|
if (publicReadableStatsMatch && request.method === 'GET') {
|
|
const id = decodePathSegment(publicReadableStatsMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getLinkStats(request, env, id, 'public');
|
|
}
|
|
|
|
const publicReadableHistoryMatch = pathname.match(/^\/api\/links\/public\/([^/]+)\/history$/);
|
|
if (publicReadableHistoryMatch && request.method === 'GET') {
|
|
const id = decodePathSegment(publicReadableHistoryMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getLinkHistory(request, env, id, 'public');
|
|
}
|
|
|
|
if (pathname === '/api/admin/public-links') {
|
|
if (request.method === 'POST') {
|
|
return await createPublicLink(request, env);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
const publicLinkStatsMatch = pathname.match(/^\/api\/admin\/public-links\/([^/]+)\/stats$/);
|
|
if (publicLinkStatsMatch && request.method === 'GET') {
|
|
const id = decodePathSegment(publicLinkStatsMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getLinkStats(request, env, id, 'public');
|
|
}
|
|
|
|
const publicLinkHistoryMatch = pathname.match(/^\/api\/admin\/public-links\/([^/]+)\/history$/);
|
|
if (publicLinkHistoryMatch && request.method === 'GET') {
|
|
const id = decodePathSegment(publicLinkHistoryMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid id' }, { status: 400 });
|
|
}
|
|
return await getLinkHistory(request, env, id, 'public');
|
|
}
|
|
|
|
const publicLinkMatch = pathname.match(/^\/api\/admin\/public-links\/([^/]+)$/);
|
|
if (publicLinkMatch) {
|
|
const id = decodePathSegment(publicLinkMatch[1]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid link id' }, { status: 400 });
|
|
}
|
|
if (request.method === 'PATCH') {
|
|
return await updatePublicLink(request, env, id);
|
|
}
|
|
if (request.method === 'DELETE') {
|
|
return await deletePublicLink(request, env, id);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// 链接详情(按ID获取)
|
|
// ═══════════════════════════════════════
|
|
const linkDetailMatch = pathname.match(/^\/api\/links\/(public|private)\/([^/]+)$/);
|
|
if (linkDetailMatch) {
|
|
const scope = linkDetailMatch[1] as 'public' | 'private';
|
|
const id = decodePathSegment(linkDetailMatch[2]);
|
|
if (!id) {
|
|
return json({ error: 'Invalid link id' }, { status: 400 });
|
|
}
|
|
if (request.method === 'GET') {
|
|
return await getLinkById(request, env, scope, id);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
const response = linkApiErrorResponse(error);
|
|
if (response) {
|
|
return response;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function listPrivateLinks(request: Request, env: Env): Promise<Response> {
|
|
const user = await requireUser(request, env);
|
|
const url = new URL(request.url);
|
|
const query = url.searchParams.get('q') ?? '';
|
|
const trimmed = query.trim();
|
|
if (trimmed) {
|
|
const escaped = escapeLikePattern(trimmed.toLowerCase());
|
|
const result = await env.DB.prepare(PRIVATE_LINK_SEARCH_QUERY)
|
|
.bind(user.id, `%${escaped}%`, trimmed.toLowerCase())
|
|
.all<LinkRow>();
|
|
return json({ links: (result.results ?? []).map(toLinkJson) });
|
|
}
|
|
const result = await env.DB.prepare(PRIVATE_LINK_LIST_QUERY).bind(user.id).all<LinkRow>();
|
|
return json({ links: (result.results ?? []).map(toLinkJson) });
|
|
}
|
|
|
|
async function getPrivateLink(request: Request, env: Env, id: string): Promise<Response> {
|
|
const user = await requireUser(request, env);
|
|
const link = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
|
|
if (!link) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
return json({ link: toLinkJson(link) });
|
|
}
|
|
|
|
async function listPublicLinks(request: Request, env: Env): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
const query = url.searchParams.get('q') ?? '';
|
|
const trimmed = query.trim();
|
|
if (trimmed) {
|
|
const escaped = escapeLikePattern(trimmed.toLowerCase());
|
|
const result = await env.DB.prepare(PUBLIC_LINK_SEARCH_QUERY)
|
|
.bind(`%${escaped}%`, trimmed.toLowerCase())
|
|
.all<LinkRow>();
|
|
return json({ links: (result.results ?? []).map(toLinkJson) });
|
|
}
|
|
const result = await env.DB.prepare(PUBLIC_LINK_LIST_QUERY).all<LinkRow>();
|
|
return json({ links: (result.results ?? []).map(toLinkJson) });
|
|
}
|
|
|
|
async function getPublicLink(env: Env, id: string): Promise<Response> {
|
|
const link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
|
|
if (!link) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
return json({ link: toLinkJson(link) });
|
|
}
|
|
|
|
async function createPrivateLink(request: Request, env: Env): Promise<Response> {
|
|
const user = await requireUser(request, env);
|
|
const input = await readAndValidateInput(request);
|
|
const duplicate = await findDuplicate(env, 'private', input.alias, user.id);
|
|
if (duplicate) {
|
|
return json({ error: 'Alias already exists' }, { status: 409 });
|
|
}
|
|
|
|
const sourcePublicLink = input.sourcePublicLinkId
|
|
? await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(input.sourcePublicLinkId).first<LinkRow>()
|
|
: null;
|
|
if (input.sourcePublicLinkId && !sourcePublicLink) {
|
|
return json({ error: 'Source public link not found' }, { status: 404 });
|
|
}
|
|
|
|
const link = await insertLink(env, 'private', user, input);
|
|
if (sourcePublicLink) {
|
|
await logLinkChange(env.DB, link.id, 'cloned_from_public', sourcePublicLink.id, sourcePublicLink.alias);
|
|
}
|
|
return json({ link }, { status: 201 });
|
|
}
|
|
|
|
async function createPublicLink(request: Request, env: Env): Promise<Response> {
|
|
const admin = await requireAdmin(request, env);
|
|
const input = await readAndValidateInput(request);
|
|
const duplicate = await findDuplicate(env, 'public', input.alias);
|
|
if (duplicate) {
|
|
return json({ error: 'Alias already exists' }, { status: 409 });
|
|
}
|
|
|
|
const link = await insertLink(env, 'public', admin, input);
|
|
return json({ link }, { status: 201 });
|
|
}
|
|
|
|
async function updatePrivateLink(request: Request, env: Env, id: string): Promise<Response> {
|
|
const user = await requireUser(request, env);
|
|
const existing = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
|
|
if (!existing) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
|
|
const input = await readAndValidateInput(request);
|
|
|
|
// Alias is immutable on update. Preserve the existing alias regardless of
|
|
// what the client sends, and skip the alias duplicate check + alias change log.
|
|
const alias = existing.alias;
|
|
await env.DB.prepare(PRIVATE_LINK_UPDATE)
|
|
.bind(alias, input.linkType, input.targetUrl, input.contentMarkdown, input.description, id, user.id)
|
|
.run();
|
|
|
|
await Promise.all([
|
|
...(existing.link_type !== input.linkType
|
|
? [logLinkChange(env.DB, id, 'type_changed', existing.link_type, input.linkType)]
|
|
: []),
|
|
...(existing.target_url !== input.targetUrl
|
|
? [logLinkChange(env.DB, id, 'url_changed', existing.target_url, input.targetUrl)]
|
|
: []),
|
|
...(existing.content_markdown !== input.contentMarkdown
|
|
? [logLinkChange(env.DB, id, 'content_changed', existing.content_markdown, input.contentMarkdown)]
|
|
: []),
|
|
...(existing.description !== input.description
|
|
? [logLinkChange(env.DB, id, 'description_changed', existing.description, input.description)]
|
|
: []),
|
|
]);
|
|
|
|
return json({
|
|
link: toLinkJson({
|
|
...existing,
|
|
alias,
|
|
link_type: input.linkType,
|
|
target_url: input.targetUrl,
|
|
content_markdown: input.contentMarkdown,
|
|
description: input.description,
|
|
updated_at: new Date().toISOString(),
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function updatePublicLink(request: Request, env: Env, id: string): Promise<Response> {
|
|
await requireAdmin(request, env);
|
|
const existing = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
|
|
if (!existing) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
|
|
const input = await readAndValidateInput(request);
|
|
|
|
// Alias is immutable on update. Preserve the existing alias regardless of
|
|
// what the client sends, and skip the alias duplicate check + alias change log.
|
|
const alias = existing.alias;
|
|
await env.DB.prepare(LINK_UPDATE)
|
|
.bind(alias, input.linkType, input.targetUrl, input.contentMarkdown, input.description, id, 'public')
|
|
.run();
|
|
|
|
await Promise.all([
|
|
...(existing.link_type !== input.linkType
|
|
? [logLinkChange(env.DB, id, 'type_changed', existing.link_type, input.linkType)]
|
|
: []),
|
|
...(existing.target_url !== input.targetUrl
|
|
? [logLinkChange(env.DB, id, 'url_changed', existing.target_url, input.targetUrl)]
|
|
: []),
|
|
...(existing.content_markdown !== input.contentMarkdown
|
|
? [logLinkChange(env.DB, id, 'content_changed', existing.content_markdown, input.contentMarkdown)]
|
|
: []),
|
|
...(existing.description !== input.description
|
|
? [logLinkChange(env.DB, id, 'description_changed', existing.description, input.description)]
|
|
: []),
|
|
]);
|
|
|
|
return json({
|
|
link: toLinkJson({
|
|
...existing,
|
|
alias,
|
|
link_type: input.linkType,
|
|
target_url: input.targetUrl,
|
|
content_markdown: input.contentMarkdown,
|
|
description: input.description,
|
|
updated_at: new Date().toISOString(),
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function deletePrivateLink(request: Request, env: Env, id: string): Promise<Response> {
|
|
const user = await requireUser(request, env);
|
|
const existing = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
|
|
if (!existing) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
|
|
await env.DB.prepare(PRIVATE_LINK_DELETE).bind(id, user.id).run();
|
|
return json({ ok: true });
|
|
}
|
|
|
|
async function deletePublicLink(request: Request, env: Env, id: string): Promise<Response> {
|
|
await requireAdmin(request, env);
|
|
const existing = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
|
|
if (!existing) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
|
|
await env.DB.prepare(PUBLIC_LINK_DELETE).bind(id).run();
|
|
return json({ ok: true });
|
|
}
|
|
|
|
async function insertLink(
|
|
env: Env,
|
|
scope: LinkScope,
|
|
user: AuthUser,
|
|
input: NormalizedLinkInput,
|
|
): Promise<ReturnType<typeof toLinkJson>> {
|
|
const id = crypto.randomUUID();
|
|
const ownerUserId = scope === 'private' ? user.id : null;
|
|
const now = new Date().toISOString();
|
|
|
|
await env.DB.prepare(LINK_INSERT)
|
|
.bind(
|
|
id,
|
|
scope,
|
|
ownerUserId,
|
|
input.alias,
|
|
input.linkType,
|
|
input.targetUrl,
|
|
input.contentMarkdown,
|
|
input.description,
|
|
)
|
|
.run();
|
|
|
|
await logLinkChange(env.DB, id, 'created', null, input.alias);
|
|
|
|
return toLinkJson({
|
|
id,
|
|
alias: input.alias,
|
|
scope,
|
|
link_type: input.linkType,
|
|
target_url: input.targetUrl,
|
|
content_markdown: input.contentMarkdown,
|
|
description: input.description,
|
|
owner_user_id: ownerUserId,
|
|
click_count: 0,
|
|
status: 'active',
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
}
|
|
|
|
async function findDuplicate(
|
|
env: Env,
|
|
scope: LinkScope,
|
|
alias: string,
|
|
ownerUserId?: string,
|
|
excludeId?: string,
|
|
): Promise<LinkRow | null> {
|
|
if (scope === 'private') {
|
|
if (excludeId) {
|
|
return env.DB.prepare(PRIVATE_DUPLICATE_EXCLUDING_QUERY).bind(ownerUserId, alias, excludeId).first<LinkRow>();
|
|
}
|
|
return env.DB.prepare(PRIVATE_DUPLICATE_QUERY).bind(ownerUserId, alias).first<LinkRow>();
|
|
}
|
|
|
|
if (excludeId) {
|
|
return env.DB.prepare(PUBLIC_DUPLICATE_EXCLUDING_QUERY).bind(alias, excludeId).first<LinkRow>();
|
|
}
|
|
return env.DB.prepare(PUBLIC_DUPLICATE_QUERY).bind(alias).first<LinkRow>();
|
|
}
|
|
|
|
async function readAndValidateInput(request: Request): Promise<NormalizedLinkInput> {
|
|
let body: unknown;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
throw new RequestValidationError('Invalid JSON body');
|
|
}
|
|
|
|
const parsed = linkInputSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
throw new RequestValidationError(parsed.error.issues[0]?.message ?? 'Invalid request body');
|
|
}
|
|
|
|
return normalizeLinkInput(parsed.data);
|
|
}
|
|
|
|
function normalizeLinkInput(input: LinkInput): NormalizedLinkInput {
|
|
const aliasValidation = validateAlias(input.alias);
|
|
if (!aliasValidation.ok) {
|
|
throw new RequestValidationError(aliasValidation.error);
|
|
}
|
|
|
|
if (input.linkType === 'custom') {
|
|
if (!input.contentMarkdown || input.contentMarkdown.trim().length === 0) {
|
|
throw new RequestValidationError('contentMarkdown is required for custom links');
|
|
}
|
|
|
|
return {
|
|
alias: aliasValidation.value,
|
|
linkType: 'custom',
|
|
targetUrl: null,
|
|
contentMarkdown: input.contentMarkdown,
|
|
description: input.description ?? null,
|
|
sourcePublicLinkId: input.sourcePublicLinkId,
|
|
};
|
|
}
|
|
|
|
if (!input.targetUrl) {
|
|
throw new RequestValidationError('targetUrl is required for redirect links');
|
|
}
|
|
|
|
if (!isHttpUrl(input.targetUrl)) {
|
|
throw new RequestValidationError('targetUrl must be an http or https URL');
|
|
}
|
|
|
|
return {
|
|
alias: aliasValidation.value,
|
|
linkType: 'redirect',
|
|
targetUrl: input.targetUrl,
|
|
contentMarkdown: null,
|
|
description: input.description ?? null,
|
|
sourcePublicLinkId: input.sourcePublicLinkId,
|
|
};
|
|
}
|
|
|
|
function isHttpUrl(value: string): boolean {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function escapeLikePattern(pattern: string): string {
|
|
return pattern.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
}
|
|
|
|
class RequestValidationError extends Error {}
|
|
|
|
function toLinkJson(row: LinkRow) {
|
|
return {
|
|
id: row.id,
|
|
alias: row.alias,
|
|
scope: row.scope,
|
|
linkType: row.link_type,
|
|
targetUrl: row.target_url,
|
|
contentMarkdown: row.content_markdown,
|
|
description: row.description,
|
|
ownerUserId: row.owner_user_id,
|
|
clickCount: row.click_count,
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
type ChangeLogRow = {
|
|
id: string;
|
|
change_type: string;
|
|
old_value: string | null;
|
|
new_value: string | null;
|
|
changed_at: string;
|
|
};
|
|
|
|
function periodToStartDate(period: string): string | null {
|
|
const now = new Date();
|
|
if (period === '3m') {
|
|
now.setDate(now.getDate() - 90);
|
|
} else if (period === '6m') {
|
|
now.setDate(now.getDate() - 180);
|
|
} else if (period === '1y') {
|
|
now.setDate(now.getDate() - 365);
|
|
} else {
|
|
return null;
|
|
}
|
|
return now.toISOString().slice(0, 10);
|
|
}
|
|
|
|
async function getLinkStats(request: Request, env: Env, id: string, scope: 'private' | 'public'): Promise<Response> {
|
|
if (scope === 'private') {
|
|
const user = await requireUser(request, env);
|
|
const link = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
|
|
if (!link) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
} else {
|
|
const link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
|
|
if (!link) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
const period = url.searchParams.get('period') ?? '3m';
|
|
const startDate = periodToStartDate(period);
|
|
|
|
type StatRow = { day: string; count: number };
|
|
let rows: StatRow[];
|
|
if (startDate) {
|
|
const result = await env.DB.prepare(CLICK_STATS_QUERY).bind(id, startDate).all<StatRow>();
|
|
rows = result.results ?? [];
|
|
} else {
|
|
const result = await env.DB.prepare(CLICK_STATS_ALL_QUERY).bind(id).all<StatRow>();
|
|
rows = result.results ?? [];
|
|
}
|
|
|
|
return json({ stats: rows, period });
|
|
}
|
|
|
|
async function getLinkHistory(request: Request, env: Env, id: string, scope: 'private' | 'public'): Promise<Response> {
|
|
if (scope === 'private') {
|
|
const user = await requireUser(request, env);
|
|
const link = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
|
|
if (!link) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
} else {
|
|
const link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
|
|
if (!link) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
}
|
|
|
|
let rows: ChangeLogRow[] = [];
|
|
try {
|
|
const result = await env.DB.prepare(CHANGE_LOG_QUERY).bind(id).all<ChangeLogRow>();
|
|
rows = result.results ?? [];
|
|
} catch (error) {
|
|
if (!isMissingTableError(error, 'link_change_logs')) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return json({
|
|
history: rows.map((row) => ({
|
|
id: row.id,
|
|
changeType: row.change_type,
|
|
oldValue: row.old_value,
|
|
newValue: row.new_value,
|
|
changedAt: row.changed_at,
|
|
})),
|
|
});
|
|
}
|
|
|
|
async function logLinkChange(
|
|
db: D1Database,
|
|
linkId: string,
|
|
changeType: string,
|
|
oldValue: string | null,
|
|
newValue: string | null,
|
|
): Promise<void> {
|
|
try {
|
|
await db.prepare(CHANGE_LOG_INSERT)
|
|
.bind(crypto.randomUUID(), linkId, changeType, oldValue, newValue)
|
|
.run();
|
|
} catch (error) {
|
|
if (!isMissingTableError(error, 'link_change_logs')) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isMissingTableError(error: unknown, tableName: string): boolean {
|
|
return error instanceof Error && error.message.toLowerCase().includes(`no such table: ${tableName}`);
|
|
}
|
|
|
|
function decodePathSegment(value: string): string | null {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getLinkById(
|
|
request: Request,
|
|
env: Env,
|
|
scope: 'public' | 'private',
|
|
id: string,
|
|
): Promise<Response> {
|
|
let link: LinkRow | null = null;
|
|
|
|
if (scope === 'public') {
|
|
link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
|
|
} else {
|
|
const user = await getCurrentUser(request, env);
|
|
if (!user) {
|
|
return json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
link = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
|
|
}
|
|
|
|
if (!link) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
|
|
return json({ link: toLinkJson(link) });
|
|
}
|
|
|
|
function json(body: unknown, init: ResponseInit = {}): Response {
|
|
return Response.json(body, {
|
|
...init,
|
|
headers: {
|
|
...jsonHeaders,
|
|
...init.headers,
|
|
},
|
|
});
|
|
}
|
|
|
|
function methodNotAllowed(): Response {
|
|
return json({ error: 'Method not allowed' }, { status: 405 });
|
|
}
|
|
|
|
export function linkApiErrorResponse(error: unknown): Response | null {
|
|
if (error instanceof RequestValidationError) {
|
|
return json({ error: error.message || 'Invalid request body' }, { status: 400 });
|
|
}
|
|
if (error instanceof AuthError) {
|
|
return json({ error: error.message }, { status: error.status });
|
|
}
|
|
if (isUniqueConstraintError(error)) {
|
|
return json({ error: 'Alias already exists' }, { status: 409 });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isUniqueConstraintError(error: unknown): boolean {
|
|
if (!(error instanceof Error)) {
|
|
return false;
|
|
}
|
|
return error.message.toLowerCase().includes('unique constraint failed');
|
|
}
|