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:
Jasper Van
2026-04-21 04:00:17 -04:00
committed by GitHub
parent 88e65b0f4e
commit 3e6d3ee63b
10 changed files with 1694 additions and 2 deletions
+27
View File
@@ -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.
+20
View File
@@ -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:**
+3
View File
@@ -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
+268
View File
@@ -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
+199
View File
@@ -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')
})
})
+94
View File
@@ -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
View File
@@ -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)
+19
View File
@@ -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>
+10
View File
@@ -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 {