mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
feat: add link CRUD APIs
This commit is contained in:
Generated
+48
-4
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260619.1",
|
||||
@@ -155,9 +156,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1406,6 +1407,40 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
@@ -2788,6 +2823,15 @@
|
||||
"@poppinss/exception": "^1.2.2",
|
||||
"error-stack-parser-es": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260619.1",
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import worker from '../worker/index';
|
||||
import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth';
|
||||
|
||||
type LinkScope = 'public' | 'private';
|
||||
type LinkStatus = 'active' | 'archived' | 'deleted';
|
||||
type LinkType = 'redirect' | 'custom';
|
||||
|
||||
type LinkRow = {
|
||||
id: string;
|
||||
scope: LinkScope;
|
||||
owner_user_id: string | null;
|
||||
alias: string;
|
||||
link_type: LinkType;
|
||||
target_url: string | null;
|
||||
content_markdown: string | null;
|
||||
description: string | null;
|
||||
status: LinkStatus;
|
||||
click_count: number;
|
||||
created_at: string;
|
||||
updated_at: 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[];
|
||||
};
|
||||
|
||||
type AllResult<T> = {
|
||||
results: T[];
|
||||
success: true;
|
||||
meta: Record<string, never>;
|
||||
};
|
||||
|
||||
class FakeD1Database {
|
||||
readonly preparedSql: string[] = [];
|
||||
readonly runCalls: RunCall[] = [];
|
||||
|
||||
constructor(
|
||||
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;
|
||||
}
|
||||
|
||||
listLinks(scope: LinkScope, ownerUserId?: string): LinkRow[] {
|
||||
return this.links
|
||||
.filter((link) => {
|
||||
if (link.scope !== scope || link.status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
return scope === 'public' ? true : link.owner_user_id === ownerUserId;
|
||||
})
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
||||
}
|
||||
|
||||
findActiveDuplicate(scope: LinkScope, alias: string, ownerUserId: string | null, excludeId?: string): LinkRow | null {
|
||||
return (
|
||||
this.links.find((link) => {
|
||||
if (link.scope !== scope || link.status !== 'active' || link.alias !== alias || link.id === excludeId) {
|
||||
return false;
|
||||
}
|
||||
return scope === 'public' ? true : link.owner_user_id === ownerUserId;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
findEditableLink(scope: LinkScope, id: string, ownerUserId?: string): LinkRow | null {
|
||||
return (
|
||||
this.links.find((link) => {
|
||||
if (link.id !== id || link.scope !== scope || link.status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
return scope === 'public' ? true : link.owner_user_id === ownerUserId;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
insertLink(params: unknown[]): void {
|
||||
const [id, scope, ownerUserId, alias, linkType, targetUrl, contentMarkdown, description] = params;
|
||||
this.links.push({
|
||||
id: String(id),
|
||||
scope: scope as LinkScope,
|
||||
owner_user_id: ownerUserId == null ? null : String(ownerUserId),
|
||||
alias: String(alias),
|
||||
link_type: linkType as LinkType,
|
||||
target_url: targetUrl == null ? null : String(targetUrl),
|
||||
content_markdown: contentMarkdown == null ? null : String(contentMarkdown),
|
||||
description: description == null ? null : String(description),
|
||||
status: 'active',
|
||||
click_count: 0,
|
||||
created_at: '2026-06-20T00:00:00.000Z',
|
||||
updated_at: '2026-06-20T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class FakeD1PreparedStatement {
|
||||
constructor(
|
||||
private readonly db: FakeD1Database,
|
||||
private readonly sql: string,
|
||||
private readonly params: unknown[] = [],
|
||||
) {}
|
||||
|
||||
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 row = this.db.findSession(String(this.params[0]));
|
||||
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('alias=?') && this.sql.includes('status=\'active\'')) {
|
||||
const scope = this.sql.includes("scope='public'") ? 'public' : 'private';
|
||||
const hasOwner = this.sql.includes('owner_user_id=?');
|
||||
const ownerUserId = hasOwner ? String(this.params[0]) : null;
|
||||
const alias = String(this.params[hasOwner ? 1 : 0]);
|
||||
const excludeId = this.sql.includes('id!=?') ? String(this.params[hasOwner ? 2 : 1]) : undefined;
|
||||
const row = this.db.findActiveDuplicate(scope, alias, ownerUserId, excludeId);
|
||||
return (row ? rowToDbResult(row) : null) as T | null;
|
||||
}
|
||||
|
||||
if (this.sql.includes('id=?') && this.sql.includes('status=\'active\'')) {
|
||||
const scope = this.sql.includes("scope='public'") ? 'public' : 'private';
|
||||
const hasOwner = this.sql.includes('owner_user_id=?');
|
||||
const id = String(this.params[0]);
|
||||
const ownerUserId = hasOwner ? String(this.params[1]) : undefined;
|
||||
const row = this.db.findEditableLink(scope, id, ownerUserId);
|
||||
return (row ? rowToDbResult(row) : null) as T | null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async all<T>(): Promise<AllResult<T>> {
|
||||
if (this.sql.includes("scope='public'")) {
|
||||
return { results: this.db.listLinks('public').map(rowToDbResult) as T[], success: true, meta: {} };
|
||||
}
|
||||
|
||||
if (this.sql.includes("scope='private'")) {
|
||||
return {
|
||||
results: this.db.listLinks('private', String(this.params[0])).map(rowToDbResult) as T[],
|
||||
success: true,
|
||||
meta: {},
|
||||
};
|
||||
}
|
||||
|
||||
return { results: [], success: true, meta: {} };
|
||||
}
|
||||
|
||||
async run(): Promise<D1Result> {
|
||||
this.db.runCalls.push({ sql: this.sql, params: this.params });
|
||||
|
||||
if (this.sql.startsWith('INSERT INTO links')) {
|
||||
this.db.insertLink(this.params);
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('UPDATE links SET') && this.sql.includes('status=\'deleted\'')) {
|
||||
const id = String(this.params[0]);
|
||||
const hasOwner = this.sql.includes('owner_user_id=?');
|
||||
const ownerUserId = hasOwner ? String(this.params[1]) : undefined;
|
||||
const scope = this.sql.includes("scope='public'") ? 'public' : 'private';
|
||||
const row = this.db.findEditableLink(scope, id, ownerUserId);
|
||||
if (row) {
|
||||
row.status = 'deleted';
|
||||
row.updated_at = '2026-06-20T00:00:01.000Z';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('UPDATE links SET') && !this.sql.includes('status=\'deleted\'')) {
|
||||
const [alias, linkType, targetUrl, contentMarkdown, description, id] = this.params;
|
||||
const row = this.db.links.find((candidate) => candidate.id === id);
|
||||
if (row) {
|
||||
row.alias = String(alias);
|
||||
row.link_type = linkType as LinkType;
|
||||
row.target_url = targetUrl == null ? null : String(targetUrl);
|
||||
row.content_markdown = contentMarkdown == null ? null : String(contentMarkdown);
|
||||
row.description = description == null ? null : String(description);
|
||||
row.updated_at = '2026-06-20T00:00:01.000Z';
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, meta: { changes: 1 } } as unknown as D1Result;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeExecutionContext {
|
||||
waitUntil(): void {}
|
||||
passThroughOnException(): void {}
|
||||
}
|
||||
|
||||
function rowToDbResult(row: LinkRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
alias: row.alias,
|
||||
scope: row.scope,
|
||||
link_type: row.link_type,
|
||||
target_url: row.target_url,
|
||||
content_markdown: row.content_markdown,
|
||||
description: row.description,
|
||||
owner_user_id: row.owner_user_id,
|
||||
click_count: row.click_count,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function link(overrides: Partial<LinkRow> = {}): LinkRow {
|
||||
return {
|
||||
id: 'link_1',
|
||||
scope: 'private',
|
||||
owner_user_id: 'user_1',
|
||||
alias: 'docs',
|
||||
link_type: 'redirect',
|
||||
target_url: 'https://example.com/docs',
|
||||
content_markdown: null,
|
||||
description: null,
|
||||
status: 'active',
|
||||
click_count: 0,
|
||||
created_at: '2026-06-20T00:00:00.000Z',
|
||||
updated_at: '2026-06-20T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function futureIso(): string {
|
||||
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
async function userSession(token: string, userId: string, role: 'user' | 'admin' = 'user'): Promise<SessionRow> {
|
||||
return {
|
||||
id: userId,
|
||||
email: `${userId}@heygo.cc`,
|
||||
name: userId,
|
||||
image_url: null,
|
||||
role,
|
||||
expires_at: futureIso(),
|
||||
session_token_hash: await hashSessionToken(token),
|
||||
};
|
||||
}
|
||||
|
||||
function cookie(token: string): string {
|
||||
return `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
||||
}
|
||||
|
||||
function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = []) {
|
||||
const db = new FakeD1Database(links, sessions);
|
||||
return { env: { DB: db as unknown as D1Database }, db, ctx: new FakeExecutionContext() };
|
||||
}
|
||||
|
||||
async function fetchWorker(
|
||||
path: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
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);
|
||||
}
|
||||
if (opts.body !== undefined) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request(`https://heygo.cc${path}`, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
}) 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 };
|
||||
}
|
||||
|
||||
async function expectJson<T = any>(response: Response): Promise<T> {
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
describe('link CRUD API', () => {
|
||||
it('creates a private link and lowercases the alias', async () => {
|
||||
const session = await userSession('token-a', 'user_1');
|
||||
const { response } = await fetchWorker('/api/links/private', {
|
||||
method: 'POST',
|
||||
cookie: cookie('token-a'),
|
||||
sessions: [session],
|
||||
body: { alias: 'My_Link', linkType: 'redirect', targetUrl: 'https://example.com/a', description: 'A' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = await expectJson(response);
|
||||
expect(body.link).toMatchObject({
|
||||
alias: 'my_link',
|
||||
scope: 'private',
|
||||
ownerUserId: 'user_1',
|
||||
linkType: 'redirect',
|
||||
targetUrl: 'https://example.com/a',
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate private aliases for the same user with 409', async () => {
|
||||
const session = await userSession('token-a', 'user_1');
|
||||
const { response } = await fetchWorker('/api/links/private', {
|
||||
method: 'POST',
|
||||
cookie: cookie('token-a'),
|
||||
sessions: [session],
|
||||
links: [link({ alias: 'docs', owner_user_id: 'user_1' })],
|
||||
body: { alias: 'Docs', linkType: 'redirect', targetUrl: 'https://example.com/new' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
await expect(expectJson(response)).resolves.toEqual({ error: 'Alias already exists' });
|
||||
});
|
||||
|
||||
it('allows the same private alias for different users', async () => {
|
||||
const session = await userSession('token-b', 'user_2');
|
||||
const { response } = await fetchWorker('/api/links/private', {
|
||||
method: 'POST',
|
||||
cookie: cookie('token-b'),
|
||||
sessions: [session],
|
||||
links: [link({ id: 'link_a', alias: 'shared', owner_user_id: 'user_1' })],
|
||||
body: { alias: 'shared', linkType: 'redirect', targetUrl: 'https://example.com/user-b' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = await expectJson(response);
|
||||
expect(body.link).toMatchObject({ alias: 'shared', ownerUserId: 'user_2' });
|
||||
});
|
||||
|
||||
it('returns 401 JSON for unauthenticated private API requests', async () => {
|
||||
const { response } = await fetchWorker('/api/links/private');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expect(expectJson(response)).resolves.toEqual({ error: 'Authentication required' });
|
||||
});
|
||||
|
||||
it('only lets a private link owner PATCH their link', async () => {
|
||||
const owner = await userSession('owner-token', 'owner');
|
||||
const other = await userSession('other-token', 'other');
|
||||
const links = [link({ id: 'private_link', alias: 'old', owner_user_id: 'owner' })];
|
||||
|
||||
const denied = await fetchWorker('/api/links/private/private_link', {
|
||||
method: 'PATCH',
|
||||
cookie: cookie('other-token'),
|
||||
sessions: [owner, other],
|
||||
links,
|
||||
body: { alias: 'new', linkType: 'redirect', targetUrl: 'https://example.com/new' },
|
||||
});
|
||||
|
||||
expect([403, 404]).toContain(denied.response.status);
|
||||
expect(links[0].alias).toBe('old');
|
||||
|
||||
const allowed = await fetchWorker('/api/links/private/private_link', {
|
||||
method: 'PATCH',
|
||||
cookie: cookie('owner-token'),
|
||||
sessions: [owner, other],
|
||||
links,
|
||||
body: { alias: 'New', linkType: 'redirect', targetUrl: 'https://example.com/new', description: 'updated' },
|
||||
});
|
||||
|
||||
expect(allowed.response.status).toBe(200);
|
||||
const body = await expectJson(allowed.response);
|
||||
expect(body.link).toMatchObject({
|
||||
id: 'private_link',
|
||||
alias: 'new',
|
||||
targetUrl: 'https://example.com/new',
|
||||
description: 'updated',
|
||||
ownerUserId: 'owner',
|
||||
});
|
||||
});
|
||||
|
||||
it('soft-deletes an owner private link', async () => {
|
||||
const session = await userSession('token-a', 'user_1');
|
||||
const links = [link({ id: 'delete_me', owner_user_id: 'user_1' })];
|
||||
const { response } = await fetchWorker('/api/links/private/delete_me', {
|
||||
method: 'DELETE',
|
||||
cookie: cookie('token-a'),
|
||||
sessions: [session],
|
||||
links,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(expectJson(response)).resolves.toEqual({ ok: true });
|
||||
expect(links[0].status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('lists only the current user active private links', async () => {
|
||||
const session = await userSession('token-a', 'user_1');
|
||||
const { response } = await fetchWorker('/api/links/private', {
|
||||
cookie: cookie('token-a'),
|
||||
sessions: [session],
|
||||
links: [
|
||||
link({ id: 'own_active', alias: 'own', owner_user_id: 'user_1', status: 'active' }),
|
||||
link({ id: 'own_deleted', alias: 'deleted', owner_user_id: 'user_1', status: 'deleted' }),
|
||||
link({ id: 'other_active', alias: 'other', owner_user_id: 'user_2', status: 'active' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await expectJson(response);
|
||||
expect(body.links.map((item: { id: string }) => item.id)).toEqual(['own_active']);
|
||||
});
|
||||
|
||||
it('lists active public links without login', async () => {
|
||||
const { response } = await fetchWorker('/api/links/public', {
|
||||
links: [
|
||||
link({ id: 'public_active', scope: 'public', owner_user_id: null, alias: 'pub', status: 'active' }),
|
||||
link({ id: 'public_deleted', scope: 'public', owner_user_id: null, alias: 'gone', status: 'deleted' }),
|
||||
link({ id: 'private_active', scope: 'private', owner_user_id: 'user_1', alias: 'priv', status: 'active' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await expectJson(response);
|
||||
expect(body.links.map((item: { id: string }) => item.id)).toEqual(['public_active']);
|
||||
});
|
||||
|
||||
it('rejects non-admin public link creation with 403', async () => {
|
||||
const session = await userSession('token-a', 'user_1', 'user');
|
||||
const { response } = await fetchWorker('/api/admin/public-links', {
|
||||
method: 'POST',
|
||||
cookie: cookie('token-a'),
|
||||
sessions: [session],
|
||||
body: { alias: 'pub', linkType: 'redirect', targetUrl: 'https://example.com/pub' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(expectJson(response)).resolves.toEqual({ error: 'Admin access required' });
|
||||
});
|
||||
|
||||
it('lets an admin create a public link', async () => {
|
||||
const session = await userSession('admin-token', 'admin_1', 'admin');
|
||||
const { response } = await fetchWorker('/api/admin/public-links', {
|
||||
method: 'POST',
|
||||
cookie: cookie('admin-token'),
|
||||
sessions: [session],
|
||||
body: { alias: 'Public', linkType: 'redirect', targetUrl: 'https://example.com/pub' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = await expectJson(response);
|
||||
expect(body.link).toMatchObject({
|
||||
alias: 'public',
|
||||
scope: 'public',
|
||||
ownerUserId: null,
|
||||
linkType: 'redirect',
|
||||
targetUrl: 'https://example.com/pub',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate public aliases with 409', async () => {
|
||||
const session = await userSession('admin-token', 'admin_1', 'admin');
|
||||
const { response } = await fetchWorker('/api/admin/public-links', {
|
||||
method: 'POST',
|
||||
cookie: cookie('admin-token'),
|
||||
sessions: [session],
|
||||
links: [link({ id: 'public_existing', scope: 'public', owner_user_id: null, alias: 'pub' })],
|
||||
body: { alias: 'Pub', linkType: 'redirect', targetUrl: 'https://example.com/new' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
await expect(expectJson(response)).resolves.toEqual({ error: 'Alias already exists' });
|
||||
});
|
||||
|
||||
it('lets an admin PATCH a public link alias, target, and content', async () => {
|
||||
const session = await userSession('admin-token', 'admin_1', 'admin');
|
||||
const links = [link({ id: 'public_link', scope: 'public', owner_user_id: null, alias: 'old' })];
|
||||
const { response } = await fetchWorker('/api/admin/public-links/public_link', {
|
||||
method: 'PATCH',
|
||||
cookie: cookie('admin-token'),
|
||||
sessions: [session],
|
||||
links,
|
||||
body: { alias: 'Updated', linkType: 'custom', contentMarkdown: '# Hello', description: 'Custom' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await expectJson(response);
|
||||
expect(body.link).toMatchObject({
|
||||
id: 'public_link',
|
||||
alias: 'updated',
|
||||
scope: 'public',
|
||||
linkType: 'custom',
|
||||
targetUrl: null,
|
||||
contentMarkdown: '# Hello',
|
||||
description: 'Custom',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an admin soft-delete a public link', async () => {
|
||||
const session = await userSession('admin-token', 'admin_1', 'admin');
|
||||
const links = [link({ id: 'public_link', scope: 'public', owner_user_id: null, alias: 'public' })];
|
||||
const { response } = await fetchWorker('/api/admin/public-links/public_link', {
|
||||
method: 'DELETE',
|
||||
cookie: cookie('admin-token'),
|
||||
sessions: [session],
|
||||
links,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(expectJson(response)).resolves.toEqual({ ok: true });
|
||||
expect(links[0].status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid aliases and invalid bodies', async () => {
|
||||
const session = await userSession('token-a', 'user_1');
|
||||
const badAlias = await fetchWorker('/api/links/private', {
|
||||
method: 'POST',
|
||||
cookie: cookie('token-a'),
|
||||
sessions: [session],
|
||||
body: { alias: '../bad', linkType: 'redirect', targetUrl: 'https://example.com' },
|
||||
});
|
||||
expect(badAlias.response.status).toBe(400);
|
||||
await expect(expectJson(badAlias.response)).resolves.toHaveProperty('error');
|
||||
|
||||
const missingTarget = await fetchWorker('/api/links/private', {
|
||||
method: 'POST',
|
||||
cookie: cookie('token-a'),
|
||||
sessions: [session],
|
||||
body: { alias: 'badbody', linkType: 'redirect' },
|
||||
});
|
||||
expect(missingTarget.response.status).toBe(400);
|
||||
await expect(expectJson(missingTarget.response)).resolves.toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Env } from './env';
|
||||
import { withPrivateNoStoreHeaders } from './lib/responses';
|
||||
import { handleLinksApi } from './routes/api.links';
|
||||
import { handlePrivateShortlink, isHeygoPrivateHost } from './routes/private-redirect';
|
||||
import { handlePublicShortlink, isHeygoPublicHost, isReservedPublicPath } from './routes/redirect';
|
||||
|
||||
@@ -32,6 +33,10 @@ export default {
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const apiResponse = await handleLinksApi(request, env);
|
||||
if (apiResponse) {
|
||||
return withPrivateHostNoStoreHeaders(url, apiResponse);
|
||||
}
|
||||
return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
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.string().url().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='active' AND owner_user_id=? AND alias=?
|
||||
LIMIT 1`;
|
||||
|
||||
const PUBLIC_DUPLICATE_QUERY = `SELECT ${LINK_COLUMNS}
|
||||
FROM links
|
||||
WHERE scope='public' AND status='active' AND alias=?
|
||||
LIMIT 1`;
|
||||
|
||||
const PRIVATE_DUPLICATE_EXCLUDING_QUERY = `SELECT ${LINK_COLUMNS}
|
||||
FROM links
|
||||
WHERE scope='private' AND status='active' 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='active' 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 === 'redirect' && !input.targetUrl) {
|
||||
throw new RequestValidationError('targetUrl is required for redirect links');
|
||||
}
|
||||
|
||||
if (input.linkType === 'custom') {
|
||||
return {
|
||||
alias: aliasValidation.value,
|
||||
linkType: 'custom',
|
||||
targetUrl: null,
|
||||
contentMarkdown: input.contentMarkdown ?? '',
|
||||
description: input.description ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: aliasValidation.value,
|
||||
linkType: 'redirect',
|
||||
targetUrl: input.targetUrl ?? null,
|
||||
contentMarkdown: input.contentMarkdown ?? null,
|
||||
description: input.description ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user