mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat: v2.4.0 T5 — /api/ihost/config + Cloudflare for SaaS integration (#316)
* 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 <noreply@anthropic.com> --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <your-server-ip>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Env>()
|
||||
.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
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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')
|
||||
})
|
||||
})
|
||||
@@ -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<CfHostnameStatus> {
|
||||
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<void> {
|
||||
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 })
|
||||
}
|
||||
+30
-2
@@ -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<string, string> = {}) {
|
||||
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)
|
||||
|
||||
@@ -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<typeof putIhostConfigSchema>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user