mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
import { z } from 'zod';
|
|
import { AuthError, requireAdmin, requireUser, 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(),
|
|
});
|
|
|
|
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;
|
|
};
|
|
|
|
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 updated_at DESC`;
|
|
|
|
const PUBLIC_LINK_LIST_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE scope='public' AND status='active'
|
|
ORDER BY updated_at DESC`;
|
|
|
|
const PRIVATE_LINK_BY_ID_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE id=? AND scope='private' AND owner_user_id=? AND status='active'
|
|
LIMIT 1`;
|
|
|
|
const PUBLIC_LINK_BY_ID_QUERY = `SELECT ${LINK_COLUMNS}
|
|
FROM links
|
|
WHERE id=? AND scope='public' AND status='active'
|
|
LIMIT 1`;
|
|
|
|
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();
|
|
}
|
|
|
|
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(env);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
if (pathname === '/api/admin/public-links') {
|
|
if (request.method === 'POST') {
|
|
return await createPublicLink(request, env);
|
|
}
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 result = await env.DB.prepare(PRIVATE_LINK_LIST_QUERY).bind(user.id).all<LinkRow>();
|
|
return json({ links: (result.results ?? []).map(toLinkJson) });
|
|
}
|
|
|
|
async function listPublicLinks(env: Env): Promise<Response> {
|
|
const result = await env.DB.prepare(PUBLIC_LINK_LIST_QUERY).all<LinkRow>();
|
|
return json({ links: (result.results ?? []).map(toLinkJson) });
|
|
}
|
|
|
|
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 link = await insertLink(env, 'private', user, input);
|
|
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(PRIVATE_LINK_BY_ID_QUERY).bind(id, user.id).first<LinkRow>();
|
|
if (!existing) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
|
|
const input = await readAndValidateInput(request);
|
|
const duplicate = await findDuplicate(env, 'private', input.alias, user.id, id);
|
|
if (duplicate) {
|
|
return json({ error: 'Alias already exists' }, { status: 409 });
|
|
}
|
|
|
|
await env.DB.prepare(PRIVATE_LINK_UPDATE)
|
|
.bind(input.alias, input.linkType, input.targetUrl, input.contentMarkdown, input.description, id, user.id)
|
|
.run();
|
|
|
|
return json({
|
|
link: toLinkJson({
|
|
...existing,
|
|
alias: input.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(PUBLIC_LINK_BY_ID_QUERY).bind(id).first<LinkRow>();
|
|
if (!existing) {
|
|
return json({ error: 'Link not found' }, { status: 404 });
|
|
}
|
|
|
|
const input = await readAndValidateInput(request);
|
|
const duplicate = await findDuplicate(env, 'public', input.alias, undefined, id);
|
|
if (duplicate) {
|
|
return json({ error: 'Alias already exists' }, { status: 409 });
|
|
}
|
|
|
|
await env.DB.prepare(LINK_UPDATE)
|
|
.bind(input.alias, input.linkType, input.targetUrl, input.contentMarkdown, input.description, id, 'public')
|
|
.run();
|
|
|
|
return json({
|
|
link: toLinkJson({
|
|
...existing,
|
|
alias: input.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(PRIVATE_LINK_BY_ID_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(PUBLIC_LINK_BY_ID_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();
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function isHttpUrl(value: string): boolean {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function decodePathSegment(value: string): string | null {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|