From 0e93faab2dd3cde3d876ef6810a5a475801a5364 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 20 Jun 2026 12:14:51 +1000 Subject: [PATCH] feat: add link CRUD APIs --- package-lock.json | 52 +++- package.json | 3 +- tests/api.links.test.ts | 563 +++++++++++++++++++++++++++++++++++++ worker/index.ts | 5 + worker/routes/api.links.ts | 442 +++++++++++++++++++++++++++++ 5 files changed, 1060 insertions(+), 5 deletions(-) create mode 100644 tests/api.links.test.ts create mode 100644 worker/routes/api.links.ts diff --git a/package-lock.json b/package-lock.json index e7ff9ce..515bea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index a097346..6003a5b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/api.links.test.ts b/tests/api.links.test.ts new file mode 100644 index 0000000..d60ee7f --- /dev/null +++ b/tests/api.links.test.ts @@ -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 = { + results: T[]; + success: true; + meta: Record; +}; + +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(): Promise { + 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(): Promise> { + 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 { + 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 { + 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 { + 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[0], + env as unknown as Parameters[1], + ctx as unknown as Parameters[2], + ); + + return { response, db }; +} + +async function expectJson(response: Response): Promise { + expect(response.headers.get('content-type')).toContain('application/json'); + return response.json() as Promise; +} + +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'); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index eebe735..dda8f64 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -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 })); } diff --git a/worker/routes/api.links.ts b/worker/routes/api.links.ts new file mode 100644 index 0000000..bc0a5f1 --- /dev/null +++ b/worker/routes/api.links.ts @@ -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; + +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 { + 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 { + const user = await requireUser(request, env); + const result = await env.DB.prepare(PRIVATE_LINK_LIST_QUERY).bind(user.id).all(); + return json({ links: (result.results ?? []).map(toLinkJson) }); +} + +async function listPublicLinks(env: Env): Promise { + const result = await env.DB.prepare(PUBLIC_LINK_LIST_QUERY).all(); + return json({ links: (result.results ?? []).map(toLinkJson) }); +} + +async function createPrivateLink(request: Request, env: Env): Promise { + 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 { + 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 { + const user = await requireUser(request, env); + const existing = await env.DB.prepare(PRIVATE_LINK_BY_ID_QUERY).bind(id, user.id).first(); + 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 { + await requireAdmin(request, env); + const existing = await env.DB.prepare(PUBLIC_LINK_BY_ID_QUERY).bind(id).first(); + 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 { + const user = await requireUser(request, env); + const existing = await env.DB.prepare(PRIVATE_LINK_BY_ID_QUERY).bind(id, user.id).first(); + 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 { + await requireAdmin(request, env); + const existing = await env.DB.prepare(PUBLIC_LINK_BY_ID_QUERY).bind(id).first(); + 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> { + 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 { + if (scope === 'private') { + if (excludeId) { + return env.DB.prepare(PRIVATE_DUPLICATE_EXCLUDING_QUERY).bind(ownerUserId, alias, excludeId).first(); + } + return env.DB.prepare(PRIVATE_DUPLICATE_QUERY).bind(ownerUserId, alias).first(); + } + + if (excludeId) { + return env.DB.prepare(PUBLIC_DUPLICATE_EXCLUDING_QUERY).bind(alias, excludeId).first(); + } + return env.DB.prepare(PUBLIC_DUPLICATE_QUERY).bind(alias).first(); +} + +async function readAndValidateInput(request: Request): Promise { + 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; +}