From 3e6d3ee63b82f2739fe0c82aabce64f9eb43e298 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Tue, 21 Apr 2026 04:00:17 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v2.4.0=20T5=20=E2=80=94=20/api/ihost/co?= =?UTF-8?q?nfig=20+=20Cloudflare=20for=20SaaS=20integration=20(#316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add /api/ihost/config endpoint with Cloudflare for SaaS integration - Add CfCustomHostnamesClient service (thin CF API wrapper; no-op when CF env vars absent) - Add /api/ihost/config route (GET/PUT/DELETE) following email-config pattern - GET lazily refreshes domain verification from CF; PUT upserts config, registers/deregisters CF hostnames; DELETE best-effort CF cleanup + row removal - PUT rejects enabled=false (must use DELETE); validates customDomain hostname format; validates refererAllowlist entries as URL origins; catches unique constraint → 409 - Add putIhostConfigSchema and IhostConfigResponse to shared schemas/types - Mount route in app.ts under /api/ihost/config - Add image_hosting_configs and image_hostings tables to test setup SQL - Add 22 integration tests covering all acceptance criteria - Update v2.4.md roadmap with config API notes; add docs/ihost-custom-domain-node.md Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * fix(ihost-config): restrict PUT/DELETE to owner role, add CF client unit tests, fix CodeQL URL check - Change requireTeamRole('editor') → requireTeamRole('owner') on PUT and DELETE (spec requires owner/admin only) - Add explicit editor-role 403 tests for PUT and DELETE - Add server/services/cf-custom-hostnames.test.ts: 16 unit tests covering register/getStatus/delete success, 409/4xx/network errors, no-op behavior, createCfClient factory - Add integration tests: GET domainStatus=verified, domainStatus=none, refererAllowlist JSON parsing, CF lazy verification active/pending paths, dnsInstructions CNAME vs manual, APP_HOST rejection, CF register on PUT, CF delete+register on domain change, CF 409 from register, clear refererAllowlist, DELETE best-effort CF cleanup (success + fail-graceful) - Replace .includes('cloudflare.com') with new URL(url).host === 'api.cloudflare.com' to fix CodeQL CWE-20 incomplete URL substring sanitization - Make createTestApp accept optional envOverrides to enable CF-configured integration tests Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * test(ihost-config): add coverage for uncovered error paths to reach 95% Add 4 targeted integration tests that cover the previously-uncovered branches in server/routes/ihost-config.ts: - PUT INSERT: CF register() throws non-CfConflict error → propagates - PUT UPDATE: CF delete() fails (best-effort console.warn) → request succeeds - PUT UPDATE: CF register() throws non-CfConflict error → propagates - PUT UPDATE: DB unique constraint on UPDATE → 409 (org2 steals org1 domain) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Bob Co-authored-by: Claude Sonnet 4.6 --- docs/ihost-custom-domain-node.md | 27 + docs/roadmap/v2.4.md | 20 + server/app.ts | 3 + .../routes/ihost-config.integration.test.ts | 1024 +++++++++++++++++ server/routes/ihost-config.ts | 268 +++++ server/services/cf-custom-hostnames.test.ts | 199 ++++ server/services/cf-custom-hostnames.ts | 94 ++ server/test/setup.ts | 32 +- shared/schemas/index.ts | 19 + shared/types/index.ts | 10 + 10 files changed, 1694 insertions(+), 2 deletions(-) create mode 100644 docs/ihost-custom-domain-node.md create mode 100644 server/routes/ihost-config.integration.test.ts create mode 100644 server/routes/ihost-config.ts create mode 100644 server/services/cf-custom-hostnames.test.ts create mode 100644 server/services/cf-custom-hostnames.ts diff --git a/docs/ihost-custom-domain-node.md b/docs/ihost-custom-domain-node.md new file mode 100644 index 00000000..a45124ca --- /dev/null +++ b/docs/ihost-custom-domain-node.md @@ -0,0 +1,27 @@ +# Image Hosting — Custom Domain (Node / Docker self-host) + +When running ZPan on Node.js (Docker) without Cloudflare Workers, custom domain SSL termination is handled by your own reverse proxy. ZPan does **not** manage DNS or certificates automatically in this mode — it simply stores the configured domain and serves images if requests arrive with the matching `Host` header. + +## Caddy example + +Add a reverse-proxy block to your `Caddyfile` (replace `img.example.com` with your domain and `127.0.0.1:3000` with your ZPan server address): + +```caddy +img.example.com { + reverse_proxy 127.0.0.1:3000 +} +``` + +Caddy obtains a Let's Encrypt certificate automatically. + +## DNS + +Point your custom domain to your server IP: + +``` +img.example.com. A +``` + +## ZPan config + +Set the custom domain via the API or web UI. The `domainStatus` field will remain `pending` (no automatic verification on Node) but images will be served correctly once DNS propagates and the reverse proxy is in place. diff --git a/docs/roadmap/v2.4.md b/docs/roadmap/v2.4.md index b25708a6..870ebae4 100644 --- a/docs/roadmap/v2.4.md +++ b/docs/roadmap/v2.4.md @@ -19,6 +19,26 @@ Turn ZPan into a proper image bed with tool ecosystem integration. - Upload history panel — recently uploaded files with one-click URL copy - Auto-copy URL to clipboard after upload (configurable format) +## Config API + +### Image Hosting Config (`/api/ihost/config`) + +A single-resource REST endpoint (GET / PUT / DELETE) that lets org owners and editors manage image hosting settings: + +- **Enable / disable** image hosting for the org. +- **Custom domain** — set a custom hostname (e.g. `img.myblog.com`). On Cloudflare Workers deployments, the domain is automatically registered via Cloudflare Custom Hostnames (CF for SaaS). GET lazily refreshes the verification status. +- **Referer allowlist** — restrict which origins may hotlink images. Each entry must be a full origin (`https://example.com`). + +#### CF Custom Hostnames env vars (Cloudflare Workers deployment) + +| Var | Description | +|-----|-------------| +| `CF_API_TOKEN` | Scoped token with Zone.Custom Hostnames edit permission | +| `CF_ZONE_ID` | The zone hosting the CNAME target | +| `CF_CNAME_TARGET` | e.g. `ssl.zpan.io` | + +When these vars are absent (Node / Docker self-host), domain registration is a no-op and `domainStatus` stays `pending`. For Caddy-based manual setup see [docs/ihost-custom-domain-node.md](../ihost-custom-domain-node.md). + ## User Scenarios **Blogger writing in Obsidian:** diff --git a/server/app.ts b/server/app.ts index 2ddcdf44..558e3cd6 100644 --- a/server/app.ts +++ b/server/app.ts @@ -8,6 +8,7 @@ import { platformMiddleware } from './middleware/platform' import type { Platform } from './platform/interface' import { adminAuthProviders, publicAuthProviders } from './routes/auth-providers' import emailConfig from './routes/email-config' +import ihostConfig from './routes/ihost-config' import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes' import { notifications } from './routes/notifications' import objects from './routes/objects' @@ -70,6 +71,7 @@ export function createApp(platform: Platform, auth: Auth) { app.route('/api/system', system) app.route('/api/admin/auth-providers', adminAuthProviders) app.route('/api/notifications', notifications) + app.route('/api/ihost/config', ihostConfig) app.get('/api/health', (c) => c.json({ status: 'ok' })) @@ -97,3 +99,4 @@ export type ProfileRoute = typeof profile export type TeamsRoute = typeof teams export type PublicTeamsRoute = typeof publicTeams export type NotificationsRoute = typeof notifications +export type IhostConfigRoute = typeof ihostConfig diff --git a/server/routes/ihost-config.integration.test.ts b/server/routes/ihost-config.integration.test.ts new file mode 100644 index 00000000..deac3854 --- /dev/null +++ b/server/routes/ihost-config.integration.test.ts @@ -0,0 +1,1024 @@ +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as authSchema from '../db/auth-schema.js' +import * as schema from '../db/schema.js' +import { createTestApp } from '../test/setup.js' + +type TestApp = Awaited>['app'] +type TestDb = Awaited>['db'] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function signUpAndGetHeaders( + app: TestApp, + email: string, +): Promise<{ headers: { Cookie: string }; userId: string }> { + const res = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test User', email, password: 'password123456' }), + }) + const cookies = res.headers.getSetCookie().join('; ') + const body = (await res.json()) as { user?: { id: string } } + return { headers: { Cookie: cookies }, userId: body.user?.id ?? '' } +} + +async function insertOrg(db: TestDb): Promise { + const id = nanoid() + await db.insert(authSchema.organization).values({ + id, + name: 'Test Org', + slug: nanoid(), + createdAt: new Date(), + }) + return id +} + +async function insertMember(db: TestDb, organizationId: string, userId: string, role = 'owner'): Promise { + await db.insert(authSchema.member).values({ + id: nanoid(), + organizationId, + userId, + role, + createdAt: new Date(), + }) +} + +async function setActiveOrg(app: TestApp, cookies: string, orgId: string): Promise { + const res = await app.request('/api/auth/organization/set-active', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookies }, + body: JSON.stringify({ organizationId: orgId }), + }) + const setCookies = res.headers.getSetCookie() + if (setCookies.length === 0) return cookies + + const updated = new Map() + for (const c of cookies.split('; ')) { + const eqIdx = c.indexOf('=') + if (eqIdx >= 0) updated.set(c.slice(0, eqIdx), c.slice(eqIdx + 1)) + } + for (const c of setCookies) { + const [pair] = c.split(';') + const eqIdx = pair.indexOf('=') + if (eqIdx >= 0) updated.set(pair.slice(0, eqIdx).trim(), pair.slice(eqIdx + 1).trim()) + } + return [...updated.entries()].map(([k, v]) => `${k}=${v}`).join('; ') +} + +async function seedConfig( + db: TestDb, + orgId: string, + overrides: Partial<{ + customDomain: string | null + cfHostnameId: string | null + domainVerifiedAt: Date | null + refererAllowlist: string | null + }> = {}, +) { + const now = new Date() + await db.insert(schema.imageHostingConfigs).values({ + orgId, + customDomain: overrides.customDomain ?? null, + cfHostnameId: overrides.cfHostnameId ?? null, + domainVerifiedAt: overrides.domainVerifiedAt ?? null, + refererAllowlist: overrides.refererAllowlist ?? null, + createdAt: now, + updatedAt: now, + }) +} + +/** Returns true if a URL string's host matches api.cloudflare.com (safe host check, no substring). */ +function isCfUrl(url: unknown): boolean { + try { + return new URL(String(url)).host === 'api.cloudflare.com' + } catch { + return false + } +} + +// ─── Unauthenticated access ──────────────────────────────────────────────────── + +describe('GET /api/ihost/config — unauth', () => { + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/ihost/config') + expect(res.status).toBe(401) + }) +}) + +describe('PUT /api/ihost/config — unauth', () => { + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }) + expect(res.status).toBe(401) + }) +}) + +describe('DELETE /api/ihost/config — unauth', () => { + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/ihost/config', { method: 'DELETE' }) + expect(res.status).toBe(401) + }) +}) + +// ─── Role enforcement ────────────────────────────────────────────────────────── + +describe('/api/ihost/config — role enforcement', () => { + it('GET allows any org member', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `member-get-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'member') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + }) + + it('GET allows viewer role', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `viewer-get-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'viewer') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + }) + + it('PUT returns 403 for member role', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `member-put-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'member') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }) + expect(res.status).toBe(403) + }) + + it('PUT returns 403 for viewer role', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `viewer-put-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'viewer') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }) + expect(res.status).toBe(403) + }) + + it('PUT returns 403 for editor role', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `editor-put-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'editor') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }) + expect(res.status).toBe(403) + }) + + it('DELETE returns 403 for member role', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `member-del-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'member') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'DELETE', + headers: { Cookie: updatedCookies }, + }) + expect(res.status).toBe(403) + }) + + it('DELETE returns 403 for editor role', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `editor-del-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'editor') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'DELETE', + headers: { Cookie: updatedCookies }, + }) + expect(res.status).toBe(403) + }) +}) + +// ─── GET ─────────────────────────────────────────────────────────────────────── + +describe('GET /api/ihost/config', () => { + it('returns { enabled: false } when no config row exists', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `get-no-config-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { enabled: boolean } + expect(body.enabled).toBe(false) + }) + + it('returns domainStatus=none and null dnsInstructions when no customDomain set', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `get-no-domain-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { domainStatus: string; dnsInstructions: null } + expect(body.domainStatus).toBe('none') + expect(body.dnsInstructions).toBeNull() + }) + + it('returns domainStatus=verified when domainVerifiedAt is set', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `get-verified-status-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const verifiedAt = new Date(Date.now() - 5000) + await seedConfig(db, orgId, { + customDomain: 'img.verified.com', + domainVerifiedAt: verifiedAt, + }) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { domainStatus: string; domainVerifiedAt: number } + expect(body.domainStatus).toBe('verified') + expect(body.domainVerifiedAt).toBeGreaterThan(0) + }) + + it('returns parsed refererAllowlist array', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `get-referer-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { + refererAllowlist: JSON.stringify(['https://blog.example.com', 'https://app.example.com']), + }) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { refererAllowlist: string[] } + expect(body.refererAllowlist).toEqual(['https://blog.example.com', 'https://app.example.com']) + }) + + it('does NOT call CF when domain already verified', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `get-no-cf-call-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { + customDomain: 'img.example.com', + cfHostnameId: 'cf-id-verified', + domainVerifiedAt: new Date(Date.now() - 1000), + }) + + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + + const cfCalls = (fetchMock.mock.calls as unknown[][]).filter(([url]) => isCfUrl(url)) + expect(cfCalls).toHaveLength(0) + + vi.unstubAllGlobals() + }) + + it('lazily verifies domain when CF getStatus returns active', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `get-lazy-verify-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { + customDomain: 'img.newdomain.com', + cfHostnameId: 'cf-pending-id', + domainVerifiedAt: null, + }) + + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ result: { status: 'active', ssl: { status: 'active' } } }), { status: 200 }), + ), + ) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { domainStatus: string; domainVerifiedAt: number } + expect(body.domainStatus).toBe('verified') + expect(body.domainVerifiedAt).toBeGreaterThan(0) + + vi.unstubAllGlobals() + }) + + it('stays pending when CF getStatus returns non-active', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `get-stay-pending-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { + customDomain: 'img.newdomain.com', + cfHostnameId: 'cf-pending-id', + domainVerifiedAt: null, + }) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: { status: 'pending', ssl: { status: 'initializing' } } }), { + status: 200, + }), + ), + ) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { domainStatus: string; domainVerifiedAt: null } + expect(body.domainStatus).toBe('pending') + expect(body.domainVerifiedAt).toBeNull() + + vi.unstubAllGlobals() + }) + + it('returns dnsInstructions with recordType=CNAME when CF is configured', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `get-cname-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const verifiedAt = new Date(Date.now() - 1000) + await seedConfig(db, orgId, { + customDomain: 'img.example.com', + domainVerifiedAt: verifiedAt, + }) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { dnsInstructions: { recordType: string; target: string } } + expect(body.dnsInstructions?.recordType).toBe('CNAME') + expect(body.dnsInstructions?.target).toBe('ssl.zpan.io') + }) + + it('returns dnsInstructions with recordType=manual when CF is not configured', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `get-manual-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'img.example.com' }) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { dnsInstructions: { recordType: string } } + expect(body.dnsInstructions?.recordType).toBe('manual') + }) + + it('returns domainStatus=pending for unverified domain when CF creds are absent', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `get-no-cf-verify-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'img.noenv.com', cfHostnameId: 'some-id' }) + + const res = await app.request('/api/ihost/config', { headers: { Cookie: updatedCookies } }) + expect(res.status).toBe(200) + const body = (await res.json()) as { domainStatus: string } + expect(body.domainStatus).toBe('pending') + }) +}) + +// ─── PUT ─────────────────────────────────────────────────────────────────────── + +describe('PUT /api/ihost/config', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('creates config row when enabled=true with no domain', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-enabled-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { enabled: boolean; customDomain: null } + expect(body.enabled).toBe(true) + expect(body.customDomain).toBeNull() + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows).toHaveLength(1) + }) + + it('creates config with customDomain (no CF configured → cfHostnameId=null, domainStatus=pending)', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-domain-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'img.example.com' }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { enabled: boolean; customDomain: string; domainStatus: string } + expect(body.enabled).toBe(true) + expect(body.customDomain).toBe('img.example.com') + expect(body.domainStatus).toBe('pending') + }) + + it('calls CF register when CF is configured and stores cfHostnameId', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `put-cf-reg-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify({ result: { id: 'cf-new-id-123' } }), { status: 200 })), + ) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'img.cf-test.com' }), + }) + expect(res.status).toBe(200) + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows[0].cfHostnameId).toBe('cf-new-id-123') + expect(rows[0].domainVerifiedAt).toBeNull() + }) + + it('changing customDomain calls CF delete then register', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `put-change-domain-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'old.example.com', cfHostnameId: 'cf-old-id' }) + + const fetchCalls: string[] = [] + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + fetchCalls.push(String(init?.method ?? 'GET')) + return new Response(JSON.stringify({ result: { id: 'cf-new-id-456' } }), { status: 200 }) + }), + ) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'new.example.com' }), + }) + expect(res.status).toBe(200) + + // First call must be DELETE (old), second must be POST (new) + expect(fetchCalls[0]).toBe('DELETE') + expect(fetchCalls[1]).toBe('POST') + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows[0].cfHostnameId).toBe('cf-new-id-456') + expect(rows[0].domainVerifiedAt).toBeNull() + expect(rows[0].customDomain).toBe('new.example.com') + }) + + it('returns 409 when CF register returns 409 conflict', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `put-cf-409-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"errors":[{"code":1403}]}', { status: 409 }))) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'taken.example.com' }), + }) + expect(res.status).toBe(409) + }) + + it('domainStatus stays pending when CF creds are absent (no crash, config row created)', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-no-cf-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'img.noclue.com' }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { domainStatus: string; enabled: boolean } + expect(body.enabled).toBe(true) + expect(body.domainStatus).toBe('pending') + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows).toHaveLength(1) + }) + + it('returns 400 when enabled=false (must use DELETE to disable)', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-disabled-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: false }), + }) + expect(res.status).toBe(400) + }) + + it('returns 400 when customDomain matches APP_HOST', async () => { + const { app, db } = await createTestApp({ APP_HOST: 'zpan.example.com' }) + const { headers, userId } = await signUpAndGetHeaders(app, `put-apphost-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'zpan.example.com' }), + }) + expect(res.status).toBe(400) + }) + + it('returns 400 for refererAllowlist entry with path component', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-bad-ref-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, refererAllowlist: ['https://foo.com/path'] }), + }) + expect(res.status).toBe(400) + }) + + it('accepts valid refererAllowlist entries including port', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-good-ref-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + refererAllowlist: ['https://example.com', 'http://localhost:3000'], + }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { refererAllowlist: string[] } + expect(body.refererAllowlist).toEqual(['https://example.com', 'http://localhost:3000']) + }) + + it('clears refererAllowlist when set to null', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-clear-ref-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { refererAllowlist: JSON.stringify(['https://old.com']) }) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, refererAllowlist: null }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { refererAllowlist: null } + expect(body.refererAllowlist).toBeNull() + }) + + it('updates existing config when called twice (second PUT overrides first)', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-update-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }) + + const res2 = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, refererAllowlist: ['https://updated.com'] }), + }) + expect(res2.status).toBe(200) + const body = (await res2.json()) as { refererAllowlist: string[] } + expect(body.refererAllowlist).toEqual(['https://updated.com']) + }) + + it('clears customDomain when set to null — domainStatus becomes none', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `put-clear-domain-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'old.example.com' }) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: null }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { customDomain: null; domainStatus: string } + expect(body.customDomain).toBeNull() + expect(body.domainStatus).toBe('none') + }) + + it('two orgs cannot register the same customDomain → 409', async () => { + const { app, db } = await createTestApp() + + const email1 = `put-dup1-${nanoid()}@example.com` + const email2 = `put-dup2-${nanoid()}@example.com` + const { headers: h1, userId: uid1 } = await signUpAndGetHeaders(app, email1) + const { headers: h2, userId: uid2 } = await signUpAndGetHeaders(app, email2) + + const orgId1 = await insertOrg(db) + const orgId2 = await insertOrg(db) + await insertMember(db, orgId1, uid1, 'owner') + await insertMember(db, orgId2, uid2, 'owner') + + await setActiveOrg(app, h1.Cookie, orgId1) + const cookies2 = await setActiveOrg(app, h2.Cookie, orgId2) + + // First org registers the domain directly in DB + await seedConfig(db, orgId1, { customDomain: 'shared.example.com' }) + + // Second org tries to register the same domain via API + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: cookies2, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'shared.example.com' }), + }) + expect(res.status).toBe(409) + }) + + it('propagates non-CfConflict CF register error on INSERT path', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `put-cf-insert-err-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + // No existing config row — triggers INSERT path + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Internal Server Error', { status: 500 }))) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'img.cf-insert-err.com' }), + }) + expect(res.status).toBeGreaterThanOrEqual(500) + }) + + it('CF delete failure on domain change is best-effort: warns and continues (UPDATE path)', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `put-cf-del-warn-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'old.warn.com', cfHostnameId: 'cf-warn-id' }) + + // DELETE returns 403 (triggers console.warn), POST (register) returns 200 + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === 'DELETE') { + return new Response('Forbidden', { status: 403 }) + } + return new Response(JSON.stringify({ result: { id: 'cf-new-warn-id' } }), { status: 200 }) + }), + ) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'new.warn.com' }), + }) + // Best-effort: delete failure should not abort the request + expect(res.status).toBe(200) + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows[0].customDomain).toBe('new.warn.com') + expect(rows[0].cfHostnameId).toBe('cf-new-warn-id') + }) + + it('propagates non-CfConflict CF register error on UPDATE path (domain change)', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `put-cf-update-err-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'old.update-err.com', cfHostnameId: 'cf-update-err-id' }) + + // DELETE succeeds, POST (register) returns 500 + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === 'DELETE') { + return new Response('{}', { status: 200 }) + } + return new Response('Internal Server Error', { status: 500 }) + }), + ) + + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: updatedCookies, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'new.update-err.com' }), + }) + expect(res.status).toBeGreaterThanOrEqual(500) + }) + + it('returns 409 when UPDATE path hits DB unique constraint (org2 takes org1 domain)', async () => { + const { app, db } = await createTestApp() + + const email1 = `put-upd-dup1-${nanoid()}@example.com` + const email2 = `put-upd-dup2-${nanoid()}@example.com` + const { userId: uid1 } = await signUpAndGetHeaders(app, email1) + const { headers: h2, userId: uid2 } = await signUpAndGetHeaders(app, email2) + + const orgId1 = await insertOrg(db) + const orgId2 = await insertOrg(db) + await insertMember(db, orgId1, uid1, 'owner') + await insertMember(db, orgId2, uid2, 'owner') + + const cookies2 = await setActiveOrg(app, h2.Cookie, orgId2) + + // Org1 owns 'claimed.example.com' + await seedConfig(db, orgId1, { customDomain: 'claimed.example.com' }) + // Org2 already has its own config with a different domain + await seedConfig(db, orgId2, { customDomain: 'other.example.com' }) + + // Org2 attempts to UPDATE its domain to org1's already-claimed domain + const res = await app.request('/api/ihost/config', { + method: 'PUT', + headers: { Cookie: cookies2, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, customDomain: 'claimed.example.com' }), + }) + expect(res.status).toBe(409) + }) +}) + +// ─── DELETE ──────────────────────────────────────────────────────────────────── + +describe('DELETE /api/ihost/config', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('returns 204 and removes config row', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `del-basic-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'DELETE', + headers: { Cookie: updatedCookies }, + }) + expect(res.status).toBe(204) + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows).toHaveLength(0) + }) + + it('returns 204 when no config row exists (idempotent)', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `del-noop-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + const res = await app.request('/api/ihost/config', { + method: 'DELETE', + headers: { Cookie: updatedCookies }, + }) + expect(res.status).toBe(204) + }) + + it('calls CF delete (best-effort) when cfHostnameId is set', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `del-cf-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'img.todelete.com', cfHostnameId: 'cf-del-id' }) + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))) + + const res = await app.request('/api/ihost/config', { + method: 'DELETE', + headers: { Cookie: updatedCookies }, + }) + expect(res.status).toBe(204) + + const calls = (vi.mocked(fetch) as ReturnType).mock.calls + const cfCalls = (calls as unknown[][]).filter(([url]) => isCfUrl(url)) + expect(cfCalls).toHaveLength(1) + const [, init] = cfCalls[0] as [string, RequestInit] + expect(init.method).toBe('DELETE') + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows).toHaveLength(0) + }) + + it('still removes row even if CF delete fails (best-effort)', async () => { + const { app, db } = await createTestApp({ + CF_API_TOKEN: 'tok', + CF_ZONE_ID: 'zone', + CF_CNAME_TARGET: 'ssl.zpan.io', + }) + const { headers, userId } = await signUpAndGetHeaders(app, `del-cf-fail-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId, { customDomain: 'img.fail.com', cfHostnameId: 'cf-fail-id' }) + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Forbidden', { status: 403 }))) + + const res = await app.request('/api/ihost/config', { + method: 'DELETE', + headers: { Cookie: updatedCookies }, + }) + // Should still return 204 even though CF call failed + expect(res.status).toBe(204) + + const rows = await db.select().from(schema.imageHostingConfigs).where(eq(schema.imageHostingConfigs.orgId, orgId)) + expect(rows).toHaveLength(0) + }) + + it('preserves image_hostings rows after config deletion', async () => { + const { app, db } = await createTestApp() + const { headers, userId } = await signUpAndGetHeaders(app, `del-preserve-${nanoid()}@example.com`) + const orgId = await insertOrg(db) + await insertMember(db, orgId, userId, 'owner') + const updatedCookies = await setActiveOrg(app, headers.Cookie, orgId) + + await seedConfig(db, orgId) + + const storageId = nanoid() + await db.insert(schema.storages).values({ + id: storageId, + title: 'Test Storage', + mode: 's3', + bucket: 'test', + endpoint: 'https://s3.example.com', + region: 'auto', + accessKey: 'key', + secretKey: 'secret', + createdAt: new Date(), + updatedAt: new Date(), + }) + + const imageId = nanoid() + await db.insert(schema.imageHostings).values({ + id: imageId, + orgId, + token: `ih_${nanoid(10)}`, + path: 'test/image.png', + storageId, + storageKey: `ih/${orgId}/${imageId}.png`, + size: 1024, + mime: 'image/png', + createdAt: new Date(), + }) + + await app.request('/api/ihost/config', { + method: 'DELETE', + headers: { Cookie: updatedCookies }, + }) + + const imageRows = await db.select().from(schema.imageHostings).where(eq(schema.imageHostings.id, imageId)) + expect(imageRows).toHaveLength(1) + }) +}) diff --git a/server/routes/ihost-config.ts b/server/routes/ihost-config.ts new file mode 100644 index 00000000..2430039d --- /dev/null +++ b/server/routes/ihost-config.ts @@ -0,0 +1,268 @@ +import { zValidator } from '@hono/zod-validator' +import { eq } from 'drizzle-orm' +import { Hono } from 'hono' +import { putIhostConfigSchema } from '../../shared/schemas' +import type { IhostConfigResponse } from '../../shared/types' +import { imageHostingConfigs } from '../db/schema' +import { requireAuth, requireTeamRole } from '../middleware/auth' +import type { Env } from '../middleware/platform' +import { CfConflictError, createCfClient } from '../services/cf-custom-hostnames' + +function toUnixMs(d: Date | null | undefined): number | null { + if (!d) return null + return d instanceof Date ? d.getTime() : null +} + +function buildResponse( + row: { + customDomain: string | null + cfHostnameId: string | null + domainVerifiedAt: Date | null + refererAllowlist: string | null + createdAt: Date + }, + cnameTarget: string, + isCfConfigured: boolean, +): IhostConfigResponse { + const verifiedAtMs = toUnixMs(row.domainVerifiedAt) + + let domainStatus: IhostConfigResponse['domainStatus'] = 'none' + if (row.customDomain) { + domainStatus = verifiedAtMs ? 'verified' : 'pending' + } + + let dnsInstructions: IhostConfigResponse['dnsInstructions'] = null + if (row.customDomain) { + dnsInstructions = { + recordType: isCfConfigured ? 'CNAME' : 'manual', + name: row.customDomain, + target: isCfConfigured ? cnameTarget : 'See docs/ihost-custom-domain-node.md for manual Caddy setup', + } + } + + const refererAllowlist = row.refererAllowlist ? (JSON.parse(row.refererAllowlist) as string[]) : null + + return { + enabled: true, + customDomain: row.customDomain, + domainVerifiedAt: verifiedAtMs, + domainStatus, + dnsInstructions, + refererAllowlist, + createdAt: row.createdAt.getTime(), + } +} + +function catchUniqueViolation(e: unknown): boolean { + const msg = e instanceof Error ? e.message : String(e) + return msg.includes('UNIQUE constraint failed') || msg.includes('unique constraint') +} + +const app = new Hono() + .use(requireAuth) + .get('/', async (c) => { + const db = c.get('platform').db + const orgId = c.get('orgId') + if (!orgId) return c.json({ error: 'Unauthorized' }, 401) + + const getEnv = c.get('platform').getEnv.bind(c.get('platform')) + const cfClient = createCfClient(getEnv) + const isCfConfigured = !!getEnv('CF_API_TOKEN') + const cnameTarget = getEnv('CF_CNAME_TARGET') ?? '' + + const rows = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1) + + if (rows.length === 0) { + return c.json({ enabled: false }) + } + + const row = rows[0] + + // Lazily refresh verification status when domain is unverified and CF is configured. + if (row.customDomain && !row.domainVerifiedAt && row.cfHostnameId && isCfConfigured) { + const status = await cfClient.getStatus(row.cfHostnameId) + if (status.status === 'active') { + const now = new Date() + await db + .update(imageHostingConfigs) + .set({ domainVerifiedAt: now, updatedAt: now }) + .where(eq(imageHostingConfigs.orgId, orgId)) + row.domainVerifiedAt = now + } + } + + return c.json(buildResponse(row, cnameTarget, isCfConfigured)) + }) + .put('/', requireTeamRole('owner'), zValidator('json', putIhostConfigSchema), async (c) => { + const db = c.get('platform').db + const orgId = c.get('orgId') + if (!orgId) return c.json({ error: 'Unauthorized' }, 401) + + const body = c.req.valid('json') + const getEnv = c.get('platform').getEnv.bind(c.get('platform')) + const cfClient = createCfClient(getEnv) + const isCfConfigured = !!getEnv('CF_API_TOKEN') + const cnameTarget = getEnv('CF_CNAME_TARGET') ?? '' + const appHost = getEnv('APP_HOST') + + // Reject the app's own default host as a custom domain. + if (body.customDomain && appHost && body.customDomain === appHost) { + return c.json({ error: 'Custom domain cannot be the application default host' }, 400) + } + + const existing = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1) + + const now = new Date() + const newDomain = body.customDomain ?? null + const newReferers = body.refererAllowlist !== undefined ? body.refererAllowlist : null + + if (existing.length === 0) { + // Insert new config row. + let cfHostnameId: string | null = null + if (newDomain && isCfConfigured) { + try { + const result = await cfClient.register(newDomain) + cfHostnameId = result.id || null + } catch (e) { + if (e instanceof CfConflictError) { + return c.json({ error: 'Domain already registered by another organization' }, 409) + } + throw e + } + } + + try { + await db.insert(imageHostingConfigs).values({ + orgId, + customDomain: newDomain, + cfHostnameId, + domainVerifiedAt: null, + refererAllowlist: newReferers ? JSON.stringify(newReferers) : null, + createdAt: now, + updatedAt: now, + }) + } catch (e) { + if (catchUniqueViolation(e)) { + return c.json({ error: 'Domain already registered by another organization' }, 409) + } + throw e + } + + return c.json( + buildResponse( + { + customDomain: newDomain, + cfHostnameId, + domainVerifiedAt: null, + refererAllowlist: newReferers ? JSON.stringify(newReferers) : null, + createdAt: now, + }, + cnameTarget, + isCfConfigured, + ), + ) + } + + // Update existing config row. + const old = existing[0] + const oldDomain = old.customDomain + let cfHostnameId = old.cfHostnameId + let domainVerifiedAt = old.domainVerifiedAt + + if (newDomain !== oldDomain) { + // Delete old CF hostname if one existed. + if (oldDomain && cfHostnameId) { + try { + await cfClient.delete(cfHostnameId) + } catch { + // Best-effort — log but don't fail so DB stays consistent. + console.warn(`CF delete failed for hostname ${cfHostnameId}; continuing`) + } + cfHostnameId = null + } + + domainVerifiedAt = null + + // Register new CF hostname if needed. + if (newDomain && isCfConfigured) { + try { + const result = await cfClient.register(newDomain) + cfHostnameId = result.id || null + } catch (e) { + if (e instanceof CfConflictError) { + return c.json({ error: 'Domain already registered by another organization' }, 409) + } + throw e + } + } + } + + const refererAllowlistValue = + body.refererAllowlist !== undefined + ? body.refererAllowlist + ? JSON.stringify(body.refererAllowlist) + : null + : old.refererAllowlist + + try { + await db + .update(imageHostingConfigs) + .set({ + customDomain: newDomain, + cfHostnameId, + domainVerifiedAt, + refererAllowlist: refererAllowlistValue, + updatedAt: now, + }) + .where(eq(imageHostingConfigs.orgId, orgId)) + } catch (e) { + if (catchUniqueViolation(e)) { + return c.json({ error: 'Domain already registered by another organization' }, 409) + } + throw e + } + + return c.json( + buildResponse( + { + customDomain: newDomain, + cfHostnameId, + domainVerifiedAt, + refererAllowlist: refererAllowlistValue, + createdAt: old.createdAt, + }, + cnameTarget, + isCfConfigured, + ), + ) + }) + .delete('/', requireTeamRole('owner'), async (c) => { + const db = c.get('platform').db + const orgId = c.get('orgId') + if (!orgId) return c.json({ error: 'Unauthorized' }, 401) + + const existing = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1) + + if (existing.length === 0) { + return c.body(null, 204) + } + + const row = existing[0] + const getEnv = c.get('platform').getEnv.bind(c.get('platform')) + const cfClient = createCfClient(getEnv) + + // Best-effort CF cleanup — do not fail if CF call errors. + if (row.cfHostnameId) { + try { + await cfClient.delete(row.cfHostnameId) + } catch { + console.warn(`CF delete failed for hostname ${row.cfHostnameId} during config DELETE; continuing`) + } + } + + await db.delete(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)) + + return c.body(null, 204) + }) + +export default app diff --git a/server/services/cf-custom-hostnames.test.ts b/server/services/cf-custom-hostnames.test.ts new file mode 100644 index 00000000..faea9311 --- /dev/null +++ b/server/services/cf-custom-hostnames.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CfConflictError, CfCustomHostnamesClient, createCfClient } from './cf-custom-hostnames.js' + +const TEST_CONFIG = { + apiToken: 'test-token', + zoneId: 'test-zone-id', + cnameTarget: 'ssl.zpan.io', +} + +function makeClient() { + return new CfCustomHostnamesClient(TEST_CONFIG) +} + +function noopClient() { + return new CfCustomHostnamesClient(null) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +// ─── createCfClient factory ──────────────────────────────────────────────────── + +describe('createCfClient', () => { + it('returns no-op client when env vars are absent', () => { + const client = createCfClient(() => undefined) + // no-op: register returns empty id, no fetch called + expect(client).toBeInstanceOf(CfCustomHostnamesClient) + }) + + it('returns configured client when all env vars are present', () => { + const client = createCfClient( + (key) => ({ CF_API_TOKEN: 'tok', CF_ZONE_ID: 'zone', CF_CNAME_TARGET: 'target' })[key], + ) + expect(client).toBeInstanceOf(CfCustomHostnamesClient) + }) + + it('returns no-op client when only some env vars are set', () => { + const client = createCfClient((key) => (key === 'CF_API_TOKEN' ? 'tok' : undefined)) + expect(client).toBeInstanceOf(CfCustomHostnamesClient) + }) +}) + +// ─── register ───────────────────────────────────────────────────────────────── + +describe('CfCustomHostnamesClient.register', () => { + it('returns { id: "" } and makes no HTTP call when no config (no-op)', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const result = await noopClient().register('img.example.com') + expect(result).toEqual({ id: '' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('calls CF API and returns hostname id on success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify({ result: { id: 'cf-abc-123' } }), { status: 200 })), + ) + + const result = await makeClient().register('img.example.com') + expect(result).toEqual({ id: 'cf-abc-123' }) + + const calls = (vi.mocked(fetch) as ReturnType).mock.calls + expect(calls).toHaveLength(1) + const [url, init] = calls[0] as [string, RequestInit] + const parsed = new URL(url) + expect(parsed.host).toBe('api.cloudflare.com') + expect(parsed.pathname).toContain('/custom_hostnames') + expect(init.method).toBe('POST') + }) + + it('throws CfConflictError on CF 409', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"errors":[{"code":1403}]}', { status: 409 }))) + + await expect(makeClient().register('img.example.com')).rejects.toThrow(CfConflictError) + }) + + it('throws generic Error on CF 500', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Internal Server Error', { status: 500 }))) + + await expect(makeClient().register('img.example.com')).rejects.toThrow(/CF registerHostname failed \(500\)/) + }) + + it('propagates network errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network failure'))) + await expect(makeClient().register('img.example.com')).rejects.toThrow('network failure') + }) +}) + +// ─── getStatus ──────────────────────────────────────────────────────────────── + +describe('CfCustomHostnamesClient.getStatus', () => { + it('returns pending with empty ssl_status when no config (no-op)', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const status = await noopClient().getStatus('any-id') + expect(status).toEqual({ status: 'pending', ssl_status: '' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns pending when id is empty string', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const status = await makeClient().getStatus('') + expect(status).toEqual({ status: 'pending', ssl_status: '' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('calls CF API and returns active status', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ result: { status: 'active', ssl: { status: 'active' } } }), { status: 200 }), + ), + ) + + const status = await makeClient().getStatus('cf-id-123') + expect(status.status).toBe('active') + expect(status.ssl_status).toBe('active') + + const calls = (vi.mocked(fetch) as ReturnType).mock.calls + const [url] = calls[0] as [string] + const parsed = new URL(url) + expect(parsed.host).toBe('api.cloudflare.com') + expect(parsed.pathname).toContain('/custom_hostnames/cf-id-123') + }) + + it('returns pending status from CF', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: { status: 'pending', ssl: { status: 'initializing' } } }), { + status: 200, + }), + ), + ) + + const status = await makeClient().getStatus('cf-id-pending') + expect(status.status).toBe('pending') + }) + + it('throws Error on CF API error response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 }))) + + await expect(makeClient().getStatus('bad-id')).rejects.toThrow(/CF getHostnameStatus failed \(404\)/) + }) +}) + +// ─── delete ─────────────────────────────────────────────────────────────────── + +describe('CfCustomHostnamesClient.delete', () => { + it('makes no HTTP call when no config (no-op)', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await noopClient().delete('cf-id-123') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('makes no HTTP call when id is empty (no-op)', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await makeClient().delete('') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('calls CF DELETE endpoint on success', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))) + + await makeClient().delete('cf-id-456') + + const calls = (vi.mocked(fetch) as ReturnType).mock.calls + expect(calls).toHaveLength(1) + const [url, init] = calls[0] as [string, RequestInit] + const parsed = new URL(url) + expect(parsed.host).toBe('api.cloudflare.com') + expect(parsed.pathname).toContain('/custom_hostnames/cf-id-456') + expect(init.method).toBe('DELETE') + }) + + it('throws Error on CF API failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Forbidden', { status: 403 }))) + + await expect(makeClient().delete('cf-id-789')).rejects.toThrow(/CF deleteHostname failed \(403\)/) + }) + + it('propagates network errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connection refused'))) + await expect(makeClient().delete('cf-id-abc')).rejects.toThrow('connection refused') + }) +}) diff --git a/server/services/cf-custom-hostnames.ts b/server/services/cf-custom-hostnames.ts new file mode 100644 index 00000000..af5b3130 --- /dev/null +++ b/server/services/cf-custom-hostnames.ts @@ -0,0 +1,94 @@ +interface CfConfig { + apiToken: string + zoneId: string + cnameTarget: string +} + +interface CfHostnameStatus { + status: 'pending' | 'active' | 'moved' | 'deleted' | 'blocked' + ssl_status: string +} + +// CfCustomHostnamesClient is a thin wrapper around the Cloudflare Custom +// Hostnames API (CF for SaaS). When env vars are absent (Node self-hosted), +// register/delete are no-ops and getStatus always returns 'pending' so +// domains never auto-verify without crashing the server. +export class CfCustomHostnamesClient { + private readonly cfg: CfConfig | null + + constructor(cfg: CfConfig | null) { + this.cfg = cfg + } + + async register(hostname: string): Promise<{ id: string }> { + if (!this.cfg) return { id: '' } + + const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.cfg.zoneId}/custom_hostnames`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.cfg.apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + hostname, + ssl: { method: 'http', type: 'dv', settings: { min_tls_version: '1.2' } }, + }), + }) + + if (!res.ok) { + const text = await res.text() + if (res.status === 409) throw new CfConflictError(`Domain already registered at Cloudflare: ${text}`) + throw new Error(`CF registerHostname failed (${res.status}): ${text}`) + } + + const data = (await res.json()) as { result: { id: string } } + return { id: data.result.id } + } + + async getStatus(id: string): Promise { + if (!this.cfg || !id) return { status: 'pending', ssl_status: '' } + + const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.cfg.zoneId}/custom_hostnames/${id}`, { + headers: { Authorization: `Bearer ${this.cfg.apiToken}` }, + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`CF getHostnameStatus failed (${res.status}): ${text}`) + } + + const data = (await res.json()) as { result: { status: string; ssl: { status: string } } } + return { + status: data.result.status as CfHostnameStatus['status'], + ssl_status: data.result.ssl?.status ?? '', + } + } + + async delete(id: string): Promise { + if (!this.cfg || !id) return + + const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.cfg.zoneId}/custom_hostnames/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${this.cfg.apiToken}` }, + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`CF deleteHostname failed (${res.status}): ${text}`) + } + } +} + +export class CfConflictError extends Error {} + +export function createCfClient(getEnv: (key: string) => string | undefined): CfCustomHostnamesClient { + const apiToken = getEnv('CF_API_TOKEN') + const zoneId = getEnv('CF_ZONE_ID') + const cnameTarget = getEnv('CF_CNAME_TARGET') + + if (!apiToken || !zoneId || !cnameTarget) { + return new CfCustomHostnamesClient(null) + } + + return new CfCustomHostnamesClient({ apiToken, zoneId, cnameTarget }) +} diff --git a/server/test/setup.ts b/server/test/setup.ts index e670c074..ed795fb4 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -193,6 +193,34 @@ const APP_SCHEMA_SQL = ` ); CREATE INDEX IF NOT EXISTS share_recipients_share_id_idx ON share_recipients(share_id); CREATE INDEX IF NOT EXISTS share_recipients_user_id_idx ON share_recipients(recipient_user_id); + CREATE TABLE IF NOT EXISTS image_hosting_configs ( + org_id TEXT PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE, + custom_domain TEXT UNIQUE, + cf_hostname_id TEXT, + domain_verified_at INTEGER, + referer_allowlist TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS image_hostings ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + storage_id TEXT NOT NULL, + storage_key TEXT NOT NULL, + size INTEGER NOT NULL, + mime TEXT NOT NULL, + width INTEGER, + height INTEGER, + status TEXT NOT NULL DEFAULT 'draft', + access_count INTEGER NOT NULL DEFAULT 0, + last_accessed_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS image_hostings_org_path_uniq ON image_hostings(org_id, path); + CREATE INDEX IF NOT EXISTS image_hostings_org_created_idx ON image_hostings(org_id, created_at); + CREATE INDEX IF NOT EXISTS image_hostings_token_idx ON image_hostings(token); CREATE TABLE IF NOT EXISTS notifications ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -237,7 +265,7 @@ const APP_SCHEMA_SQL = ` CREATE INDEX IF NOT EXISTS image_hostings_token_idx ON image_hostings(token); ` -export async function createTestApp() { +export async function createTestApp(envOverrides: Record = {}) { const sqlite = new Database(':memory:') sqlite.exec(AUTH_SCHEMA_SQL) sqlite.exec(APP_SCHEMA_SQL) @@ -245,7 +273,7 @@ export async function createTestApp() { const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } }) const platform: Platform = { db, - getEnv: () => undefined, + getEnv: (key: string) => envOverrides[key], } const auth = await createAuth(db, 'test-secret', 'http://localhost:3000') const app = createApp(platform, auth) diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index 88b4b6c7..bea97fe5 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -105,3 +105,22 @@ export const batchPatchSchema = z.discriminatedUnion('action', [ export const batchDeleteSchema = z.object({ ids: z.array(z.string().min(1)).min(1), }) + +// Valid hostname regex: lowercase labels separated by dots, max 253 chars total, +// each label max 63 chars, no leading/trailing dots, no port. +const hostnameRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/ + +// Valid referer origin: protocol + host + optional port, no path/query. +const refererOriginRegex = /^https?:\/\/[a-zA-Z0-9.-]+(:\d+)?$/ + +export const putIhostConfigSchema = z.object({ + enabled: z.literal(true), + customDomain: z.string().max(253).regex(hostnameRegex, 'Invalid hostname format').nullable().optional(), + refererAllowlist: z + .array(z.string().regex(refererOriginRegex, 'Each entry must be a valid origin (e.g. https://example.com)')) + .max(50) + .nullable() + .optional(), +}) + +export type PutIhostConfigInput = z.infer diff --git a/shared/types/index.ts b/shared/types/index.ts index 641d6692..9df1680c 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -193,6 +193,16 @@ export interface ImageHostingConfig { updatedAt: string } +export interface IhostConfigResponse { + enabled: boolean + customDomain: string | null + domainVerifiedAt: number | null + domainStatus: 'none' | 'pending' | 'verified' + dnsInstructions: { recordType: string; name: string; target: string } | null + refererAllowlist: string[] | null + createdAt: number +} + export type ImageHostingStatus = 'draft' | 'active' export interface ImageHosting {