diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index bf9f4dbd..2066f295 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -169,7 +169,6 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - ZPAN_DEPLOY_URL: ${{ vars.ZPAN_DEPLOY_URL }} # Keep post-deploy hooks inside the repository deploy command so # GitHub Actions and Cloudflare Workers Builds can share the same path. run: pnpm deploy diff --git a/package.json b/package.json index 6b037131..8d241fb8 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --format esm --outDir api --external @libsql/client", "build:netlify": "tsup server/entry-netlify.ts --format esm --outDir netlify/functions --external @libsql/client", "build:azure": "vite build && tsup server/entry-azure.ts --format esm --outDir azure-functions --external @azure/functions --external @libsql/client && cp server/azure-host.json azure-functions/host.json && cp -r dist azure-functions/dist", - "deploy": "pnpm db:migrate:d1:prod && wrangler deploy && node scripts/report-deploy-telemetry.mjs", + "deploy": "pnpm db:migrate:d1:prod && wrangler deploy", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:migrate:d1": "wrangler d1 migrations apply zpan-db-staging --local --env staging", diff --git a/scripts/report-deploy-telemetry.mjs b/scripts/report-deploy-telemetry.mjs index 8e316c07..db746b3a 100644 --- a/scripts/report-deploy-telemetry.mjs +++ b/scripts/report-deploy-telemetry.mjs @@ -3,7 +3,6 @@ import { randomBytes } from 'node:crypto' const INTERNAL_TOKEN_ENV = 'ZPAN_INTERNAL_API_TOKEN' const REPORT_PATH = '/api/internal/instance-telemetry/report' -const DEFAULT_DEPLOY_URL = 'https://zpan.saltbo.workers.dev' const TOP_LEVEL_ENV_ARG = '--env=' const SECRET_PROPAGATION_DELAY_MS = 5000 const REPORT_RETRY_COUNT = 12 @@ -23,9 +22,10 @@ if (process.env.GITHUB_ACTIONS === 'true') { let secretSet = false try { + const deployUrl = resolveDeployUrl() putInternalToken(token) secretSet = true - const reported = await reportDeployTelemetry(token) + const reported = await reportDeployTelemetry(token, deployUrl) if (!reported) { console.warn('Deploy telemetry report was not delivered after retries; continuing deploy.') } @@ -58,8 +58,8 @@ function deleteInternalToken() { if (res.status !== 0) throw new Error(`wrangler secret delete failed status=${res.status ?? 1}`) } -async function reportDeployTelemetry(internalToken) { - const url = new URL(REPORT_PATH, resolveDeployUrl()) +async function reportDeployTelemetry(internalToken, deployUrl) { + const url = new URL(REPORT_PATH, deployUrl) await sleep(SECRET_PROPAGATION_DELAY_MS) for (let attempt = 1; attempt <= REPORT_RETRY_COUNT; attempt += 1) { @@ -87,7 +87,15 @@ async function reportDeployTelemetry(internalToken) { } function resolveDeployUrl() { - return process.env.ZPAN_DEPLOY_URL?.trim() || process.env.BETTER_AUTH_URL?.trim() || DEFAULT_DEPLOY_URL + const value = process.argv[2]?.trim() + if (!value) { + throw new Error('Deploy telemetry URL is required. Usage: node scripts/report-deploy-telemetry.mjs https://your-zpan.example') + } + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Deploy telemetry URL must use http or https: ${value}`) + } + return url.origin } function sleep(ms) { diff --git a/scripts/run-cloud-e2e.mjs b/scripts/run-cloud-e2e.mjs index 579d9e6e..328177ec 100644 --- a/scripts/run-cloud-e2e.mjs +++ b/scripts/run-cloud-e2e.mjs @@ -36,7 +36,6 @@ const tunnelEnv = { E2E_APP_PORT: String(appPort), E2E_API_PORT: String(apiPort), BETTER_AUTH_URL: baseUrl, - ZPAN_INSTANCE_ID: process.env.ZPAN_INSTANCE_ID ?? `zpan-e2e-${runtime}`, TRUSTED_ORIGINS: `${baseUrl},${localBaseUrl}`, ...(tunnel ? { E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}` } : {}), } diff --git a/server/app.ts b/server/app.ts index 8df8f8fa..91bdf70e 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,3 +1,5 @@ +import { release as osRelease } from 'node:os' +import type { Context } from 'hono' import { Hono } from 'hono' import { cors } from 'hono/cors' import type { Auth } from './auth' @@ -37,12 +39,40 @@ import { publicTeams, teams } from './routes/teams' import trash from './routes/trash' import users from './routes/users' import webdav from './routes/webdav' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/instance-telemetry' +import { ensureSitePublicOrigin } from './services/site-public-origin' export function createApp(platform: Platform, auth: Auth) { const app = new Hono() const corsOrigins = getCorsOrigins(platform) app.use('/*', platformMiddleware(platform, auth)) + app.use('/*', async (c, next) => { + const result = await ensureSitePublicOrigin(platform.db, c.req.url).catch((err) => { + const code = err instanceof Error ? err.message : String(err) + console.error(`site.public_origin.detect.error code=${code}`) + return { origin: null, created: false } + }) + + if (result.created && result.origin && shouldReportInitialTelemetry(c.req.url)) { + const task = reportInstanceTelemetry({ + db: platform.db, + config: { + siteUrl: result.origin, + allowIp: envAllowsIp(platform.getEnv('ZPAN_TELEMETRY_ALLOW_IP')), + }, + cron: INSTANCE_TELEMETRY_CRON, + trigger: 'runtime', + runtime: instanceTelemetryRuntime(platform), + }).catch((err) => { + const code = err instanceof Error ? err.message : String(err) + console.error(`instance.telemetry.initial_report.error code=${code}`) + }) + waitUntil(c, task) + } + + await next() + }) app.use('/*', imageHostingDomain) app.use('/api/*', accessLog) app.use('/dav', accessLog) @@ -122,6 +152,43 @@ export function createApp(platform: Platform, auth: Auth) { return app } +function envAllowsIp(value: string | undefined): boolean { + return !['0', 'false', 'no', 'off'].includes(value?.trim().toLowerCase() ?? '') +} + +function instanceTelemetryRuntime(platform: Platform) { + if (platform.getBinding('DB')) { + return { + target: 'cloudflare-worker' as const, + provider: 'cloudflare' as const, + } + } + + return { + target: 'node/docker' as const, + provider: 'node' as const, + osPlatform: process.platform, + osArch: process.arch, + osRelease: osRelease(), + nodeVersion: process.version, + } +} + +function waitUntil(c: Context, task: Promise): void { + try { + c.executionCtx.waitUntil(task) + return + } catch { + void task + } +} + +function shouldReportInitialTelemetry(requestUrl: string): boolean { + const url = new URL(requestUrl) + if (url.pathname === '/api/internal/instance-telemetry/report') return false + return !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) +} + function getCorsOrigins(platform: Platform): Set { const origins = new Set() const addOrigin = (value: string | undefined) => { diff --git a/server/entry-node.ts b/server/entry-node.ts index 99c7fe52..36613c2a 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -11,6 +11,7 @@ import { syncPendingCloudTrafficReports } from './services/cloud-traffic-meterin import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/instance-telemetry' import { runLicensingRefresh } from './services/licensing-refresh-runner' import { syncPendingRemoteDownloadUsageReports } from './services/remote-download-usage' +import { getSitePublicOrigin } from './services/site-public-origin' const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes @@ -37,18 +38,6 @@ serve({ fetch: server.fetch, port }) // Start licensing refresh background scheduler const cloudBaseUrl = process.env.ZPAN_CLOUD_URL ?? ZPAN_CLOUD_URL_DEFAULT -function configuredPublicOrigin(): string | null { - const value = process.env.ZPAN_PUBLIC_ORIGIN ?? process.env.BETTER_AUTH_URL - if (!value) return null - try { - const url = new URL(value) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null - return url.origin - } catch { - return null - } -} - function envAllowsIp(value: string | undefined): boolean { return !['0', 'false', 'no', 'off'].includes(value?.trim().toLowerCase() ?? '') } @@ -61,10 +50,9 @@ console.log('licensing.refresh.scheduler.started interval=6h') setInterval(() => { // runLicensingRefresh handles all errors internally and never rejects. void (async () => { - const instanceUrl = configuredPublicOrigin() + const instanceUrl = await getSitePublicOrigin(platform.db) const instance = instanceUrl ? await buildCloudInstanceInfo(platform.db, { - configuredInstanceId: process.env.ZPAN_INSTANCE_ID, url: instanceUrl, runtime: { runtime: { provider: 'node', target: 'node/docker' }, @@ -91,8 +79,6 @@ function reportNodeInstanceTelemetry(): void { await reportInstanceTelemetry({ db: platform.db, config: { - configuredInstanceId: process.env.ZPAN_INSTANCE_ID, - siteUrl: process.env.ZPAN_PUBLIC_ORIGIN ?? process.env.BETTER_AUTH_URL, allowIp: envAllowsIp(process.env.ZPAN_TELEMETRY_ALLOW_IP), }, cron: INSTANCE_TELEMETRY_CRON, diff --git a/server/licensing/instance-id.ts b/server/licensing/instance-id.ts index 8145028b..2b20ff25 100644 --- a/server/licensing/instance-id.ts +++ b/server/licensing/instance-id.ts @@ -5,9 +5,7 @@ import type { Database } from '../platform/interface' const INSTANCE_ID_KEY = 'instance_id' -// Returns the instance UUID, creating and persisting one if it does not exist. -// The ID is stored in systemOptions under key 'instance_id'. -export async function getOrCreateInstanceId(db: Database, configuredId?: string): Promise { +export async function getOrCreateInstanceId(db: Database): Promise { const rows = await db .select({ value: systemOptions.value }) .from(systemOptions) @@ -16,7 +14,7 @@ export async function getOrCreateInstanceId(db: Database, configuredId?: string) if (rows[0]?.value) return rows[0].value - const id = configuredId?.trim() || nanoid(21) + const id = nanoid(21) await db .insert(systemOptions) .values({ key: INSTANCE_ID_KEY, value: id, public: false }) diff --git a/server/licensing/instance-info.ts b/server/licensing/instance-info.ts index e7fce0c5..acb2de5e 100644 --- a/server/licensing/instance-info.ts +++ b/server/licensing/instance-info.ts @@ -19,11 +19,10 @@ export async function buildCloudInstanceInfo( db: Database, params: { url: string - configuredInstanceId?: string runtime?: Pick }, ): Promise { - const instanceId = await getOrCreateInstanceId(db, params.configuredInstanceId) + const instanceId = await getOrCreateInstanceId(db) return { id: instanceId, name: await getInstanceDisplayName(db), diff --git a/server/middleware/require-feature.ts b/server/middleware/require-feature.ts index 14157a4f..d0d09d4e 100644 --- a/server/middleware/require-feature.ts +++ b/server/middleware/require-feature.ts @@ -4,25 +4,20 @@ import { createMiddleware } from 'hono/factory' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import { hasFeature, loadBindingState } from '../licensing/has-feature' import { normalizeHost } from '../licensing/verify' +import { getSitePublicOrigin } from '../services/site-public-origin' import type { Env } from './platform' -function configuredPublicHost(c: Context): string | null { - const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL') - if (!value) return null - try { - const url = new URL(value) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null - return url.host - } catch { - return null - } +async function configuredPublicHost(c: Context): Promise { + const origin = await getSitePublicOrigin(c.get('platform').db) + return origin ? new URL(origin).host : null } export function requireFeature(name: ProFeature) { return createMiddleware(async (c, next) => { const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT - const currentHost = configuredPublicHost(c) ?? normalizeHost(c.req.header('host')) ?? new URL(c.req.url).host + const currentHost = + (await configuredPublicHost(c)) ?? normalizeHost(c.req.header('host')) ?? new URL(c.req.url).host const state = await loadBindingState(db, { currentHost, cloudBaseUrl }) if (!hasFeature(name, state)) { return c.json({ error: 'feature_not_available', feature: name, upgrade_url: '/settings/billing' }, 402) diff --git a/server/routes/cloud-store.integration.test.ts b/server/routes/cloud-store.integration.test.ts index 15a70b86..6f5479fc 100644 --- a/server/routes/cloud-store.integration.test.ts +++ b/server/routes/cloud-store.integration.test.ts @@ -490,7 +490,7 @@ describe('Quota Store API', () => { }) }) - it('uses https origin for non-local http request URLs', async () => { + it('uses the detected site origin for checkout return URLs', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -504,15 +504,15 @@ describe('Quota Store API', () => { expect(checkout.status).toBe(200) expect(orderPayload()).toMatchObject({ - deliveryCallbackUrl: 'https://files.example.com/api/store/webhook', + deliveryCallbackUrl: 'http://localhost/api/store/webhook', }) expect(paymentPayload()).toMatchObject({ - successUrl: 'https://files.example.com/storage', - cancelUrl: 'https://files.example.com/storage', + successUrl: 'http://localhost/storage', + cancelUrl: 'http://localhost/storage', }) }) - it('uses configured auth URL origin for checkout return URLs', async () => { + it('does not use auth URL as checkout return URL configuration', async () => { const { app, db } = await createTestApp({ BETTER_AUTH_URL: 'https://auth.example.com/path' }) await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -524,28 +524,6 @@ describe('Quota Store API', () => { body: JSON.stringify({ packageId }), }) - expect(checkout.status).toBe(200) - expect(orderPayload()).toMatchObject({ - deliveryCallbackUrl: 'https://auth.example.com/api/store/webhook', - }) - expect(paymentPayload()).toMatchObject({ - successUrl: 'https://auth.example.com/storage', - cancelUrl: 'https://auth.example.com/storage', - }) - }) - - it('falls back to request origin when public origin env is invalid', async () => { - const { app, db } = await createTestApp({ ZPAN_PUBLIC_ORIGIN: 'not a url' }) - await seedBusinessLicense(db) - const headers = await authedHeaders(app, 'buyer@example.com') - const packageId = await seedPackage(db) - - const checkout = await app.request('/api/store/checkouts', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ packageId }), - }) - expect(checkout.status).toBe(200) expect(orderPayload()).toMatchObject({ deliveryCallbackUrl: 'http://localhost/api/store/webhook', @@ -556,8 +534,34 @@ describe('Quota Store API', () => { }) }) - it('falls back to request origin when public origin env uses an unsupported scheme', async () => { - const { app, db } = await createTestApp({ ZPAN_PUBLIC_ORIGIN: 'ftp://files.example.com' }) + it('uses configured site public origin for checkout URLs', async () => { + const { app, db } = await createTestApp() + await db.run(sql` + INSERT INTO system_options (key, value, public) + VALUES ('site_public_origin', 'https://files.example.com/path', 0) + `) + await seedBusinessLicense(db) + const headers = await authedHeaders(app, 'buyer@example.com') + const packageId = await seedPackage(db) + + const checkout = await app.request('/api/store/checkouts', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ packageId }), + }) + + expect(checkout.status).toBe(200) + expect(orderPayload()).toMatchObject({ + deliveryCallbackUrl: 'https://files.example.com/api/store/webhook', + }) + expect(paymentPayload()).toMatchObject({ + successUrl: 'https://files.example.com/storage', + cancelUrl: 'https://files.example.com/storage', + }) + }) + + it('falls back to request origin when site public origin is not configured', async () => { + const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') const packageId = await seedPackage(db) diff --git a/server/routes/cloud-store/shared.ts b/server/routes/cloud-store/shared.ts index bb4569ff..86b1fc4f 100644 --- a/server/routes/cloud-store/shared.ts +++ b/server/routes/cloud-store/shared.ts @@ -1,4 +1,5 @@ import type { z } from 'zod' +import { getSitePublicOrigin, originFromRequestUrl } from '../../services/site-public-origin' import { cloudOrdersResponseSchema, getBoundCloudClient, @@ -32,27 +33,8 @@ export async function getCloudOrders( } } -export function getInstanceOrigin(c: RouteContext): string { - const configuredOrigin = publicOriginFromEnv( - c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL'), - ) +export async function getInstanceOrigin(c: RouteContext): Promise { + const configuredOrigin = await getSitePublicOrigin(c.get('platform').db) if (configuredOrigin) return configuredOrigin - const requestUrl = new URL(c.req.url) - if (requestUrl.protocol === 'https:' || isLocalHost(requestUrl.hostname)) return requestUrl.origin - return `https://${requestUrl.host}` -} - -function publicOriginFromEnv(value: string | undefined): string | null { - if (!value) return null - try { - const url = new URL(value) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null - return url.origin - } catch { - return null - } -} - -function isLocalHost(hostname: string) { - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' + return originFromRequestUrl(c.req.url) ?? new URL(c.req.url).origin } diff --git a/server/routes/cloud-store/storefront.ts b/server/routes/cloud-store/storefront.ts index 4b4ecea1..4f34fe8f 100644 --- a/server/routes/cloud-store/storefront.ts +++ b/server/routes/cloud-store/storefront.ts @@ -150,6 +150,7 @@ export const cloudStore = new Hono() const quota = await getEffectiveQuota(db, targetOrgId) if (quota.currentPlan?.subscription) return c.json({ error: 'workspace_plan_exists' }, 409) } + const origin = await getInstanceOrigin(c) const order = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( await client.stores[':storeId'].orders.$post({ @@ -157,7 +158,7 @@ export const cloudStore = new Hono() json: { items: [{ productId: body.packageId, priceId: price.id, quantity: 1 }], currency, - deliveryCallbackUrl: `${getInstanceOrigin(c)}/api/store/webhook`, + deliveryCallbackUrl: `${origin}/api/store/webhook`, target: { orgId: targetOrgId, customerId: targetOrgId, @@ -169,7 +170,6 @@ export const cloudStore = new Hono() ), ) if (isCloudError(order)) return c.json(order, 502) - const origin = getInstanceOrigin(c) const payment = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( await client.stores[':storeId'].orders[':orderId'].payments.$post({ @@ -194,7 +194,7 @@ export const cloudStore = new Hono() const store = await getUserStoreSettings(db) if ('error' in store) return c.json({ error: store.error }, 403) - const origin = getInstanceOrigin(c) + const origin = await getInstanceOrigin(c) const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( await client.stores[':storeId'].billing['portal-sessions'].$post({ @@ -234,7 +234,7 @@ export const cloudStore = new Hono() const order = await getOrder(c, orderId) if (isCloudError(order)) return c.json(order, 502) if (!orderBelongsToTarget(order.target, targetOrgId)) return c.json({ error: 'Forbidden' }, 403) - const origin = getInstanceOrigin(c) + const origin = await getInstanceOrigin(c) const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( await client.stores[':storeId'].orders[':orderId'].payments.$post({ diff --git a/server/routes/internal.test.ts b/server/routes/internal.test.ts index 8dc31ea4..eb90fcad 100644 --- a/server/routes/internal.test.ts +++ b/server/routes/internal.test.ts @@ -40,8 +40,6 @@ describe('POST /api/internal/instance-telemetry/report', () => { it('reports telemetry with the configured internal token', async () => { const { app, db } = await createTestApp({ ZPAN_INTERNAL_API_TOKEN: 'test-token', - ZPAN_INSTANCE_ID: 'configured-instance', - BETTER_AUTH_URL: 'https://zpan.example.com/path', }) const res = await app.request('/api/internal/instance-telemetry/report', { @@ -54,8 +52,6 @@ describe('POST /api/internal/instance-telemetry/report', () => { expect(reportInstanceTelemetry).toHaveBeenCalledWith({ db, config: { - configuredInstanceId: 'configured-instance', - siteUrl: 'https://zpan.example.com/path', allowIp: true, }, cron: '0 */12 * * *', diff --git a/server/routes/internal.ts b/server/routes/internal.ts index 4382e1cb..5cca0100 100644 --- a/server/routes/internal.ts +++ b/server/routes/internal.ts @@ -36,8 +36,6 @@ internal.post('/instance-telemetry/report', async (c) => { const result = await reportInstanceTelemetry({ db: platform.db, config: { - configuredInstanceId: platform.getEnv('ZPAN_INSTANCE_ID'), - siteUrl: platform.getEnv('ZPAN_PUBLIC_ORIGIN') ?? platform.getEnv('BETTER_AUTH_URL'), allowIp: envAllowsIp(platform.getEnv('ZPAN_TELEMETRY_ALLOW_IP')), }, cron: INSTANCE_TELEMETRY_CRON, diff --git a/server/routes/licensing-admin.integration.test.ts b/server/routes/licensing-admin.integration.test.ts index 07f29604..03bd0e84 100644 --- a/server/routes/licensing-admin.integration.test.ts +++ b/server/routes/licensing-admin.integration.test.ts @@ -132,28 +132,6 @@ describe('POST /api/licensing/pair', () => { const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(JSON.parse(String(init.body)).instance.url).toBe('http://localhost') }) - - it('uses the configured instance id when present', async () => { - const { app } = await createTestApp({ ZPAN_INSTANCE_ID: 'zpan-e2e-node' }) - const headers = await adminHeaders(app) - - vi.mocked(fetch).mockResolvedValueOnce( - makeCloudResponse({ - code: 'ABC-123', - pairingUrl: 'https://cloud.zpan.space/pair', - expiresAt: '2026-01-01T00:00:00Z', - }), - ) - - const res = await app.request('/api/licensing/pair', { - method: 'POST', - headers, - }) - - expect(res.status).toBe(200) - const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect(JSON.parse(String(init.body)).instance.id).toBe('zpan-e2e-node') - }) }) describe('GET /api/licensing/pair/:code/poll', () => { diff --git a/server/routes/licensing-admin.ts b/server/routes/licensing-admin.ts index 8a0d915e..42a8e161 100644 --- a/server/routes/licensing-admin.ts +++ b/server/routes/licensing-admin.ts @@ -11,29 +11,12 @@ import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' import { recordActivity } from '../services/activity' import { createPairing, pollPairing, unbindCloudLicense } from '../services/licensing-cloud' +import { getSitePublicOrigin, originFromRequestUrl } from '../services/site-public-origin' function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string { return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT } -function configuredInstanceId(c: { - get(key: 'platform'): { getEnv(k: string): string | undefined } -}): string | undefined { - return c.get('platform').getEnv('ZPAN_INSTANCE_ID') -} - -function configuredPublicOrigin(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string | null { - const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL') - if (!value) return null - try { - const url = new URL(value) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null - return url.origin - } catch { - return null - } -} - function runtimeInfo(c: { get(key: 'platform'): { getBinding(key: string): T | undefined @@ -49,28 +32,20 @@ function runtimeInfo(c: { } } -function getInstanceOrigin(c: { - get(key: 'platform'): { getEnv(k: string): string | undefined } +async function getInstanceOrigin(c: { + get(key: 'platform'): { db: import('../platform/interface').Database } req: { url: string; header(name: string): string | undefined } -}): string { - const configured = configuredPublicOrigin(c) +}): Promise { + const configured = await getSitePublicOrigin(c.get('platform').db) if (configured) return configured - const requestUrl = new URL(c.req.url) - const forwardedProto = c.req.header('x-forwarded-proto') - const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host') - - if (forwardedProto && forwardedHost) { - return `${forwardedProto}://${forwardedHost}` - } - - return requestUrl.origin + return originFromRequestUrl(c.req.url) ?? new URL(c.req.url).origin } -function getRequestHost(c: { - get(key: 'platform'): { getEnv(k: string): string | undefined } +async function getRequestHost(c: { + get(key: 'platform'): { db: import('../platform/interface').Database } req: { url: string; header(name: string): string | undefined } -}): string { - const configured = configuredPublicOrigin(c) +}): Promise { + const configured = await getSitePublicOrigin(c.get('platform').db) if (configured) return new URL(configured).host const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host') return normalizeHost(forwardedHost) ?? new URL(c.req.url).host @@ -84,8 +59,7 @@ const app = new Hono() const baseUrl = getCloudBaseUrl(c) const instance = await buildCloudInstanceInfo(db, { - configuredInstanceId: configuredInstanceId(c), - url: getInstanceOrigin(c), + url: await getInstanceOrigin(c), runtime: runtimeInfo(c), }) @@ -107,11 +81,11 @@ const app = new Hono() binding: result.binding, account: result.account, } - const instanceId = await getOrCreateInstanceId(db, configuredInstanceId(c)) + const instanceId = await getOrCreateInstanceId(db) const cert = entitlement.certificate const assertion = verifyCertificate(cert, { instanceId, - currentHost: getRequestHost(c), + currentHost: await getRequestHost(c), cloudBaseUrl: baseUrl, }) if (!assertion || !entitlement.binding?.storeId || !entitlement.account) { @@ -163,8 +137,7 @@ const app = new Hono() const orgId = c.get('orgId')! const baseUrl = getCloudBaseUrl(c) const instance = await buildCloudInstanceInfo(db, { - configuredInstanceId: configuredInstanceId(c), - url: getInstanceOrigin(c), + url: await getInstanceOrigin(c), runtime: runtimeInfo(c), }) diff --git a/server/routes/licensing.ts b/server/routes/licensing.ts index a692c91c..0bedd0b2 100644 --- a/server/routes/licensing.ts +++ b/server/routes/licensing.ts @@ -11,26 +11,15 @@ import type { Env } from '../middleware/platform' import { syncPendingCloudTrafficReports } from '../services/cloud-traffic-metering' import { runLicensingRefresh } from '../services/licensing-refresh-runner' import { syncPendingRemoteDownloadUsageReports } from '../services/remote-download-usage' +import { getSitePublicOrigin, originFromRequestUrl } from '../services/site-public-origin' -function configuredPublicHost(c: Context): string | null { - const origin = configuredPublicOrigin(c) +async function configuredPublicHost(c: Context): Promise { + const origin = await getInstanceOrigin(c) return origin ? new URL(origin).host : null } -function configuredPublicOrigin(c: Context): string | null { - const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL') - if (!value) return null - try { - const url = new URL(value) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null - return url.origin - } catch { - return null - } -} - -function configuredInstanceId(c: Context): string | undefined { - return c.get('platform').getEnv('ZPAN_INSTANCE_ID') +async function getInstanceOrigin(c: Context): Promise { + return (await getSitePublicOrigin(c.get('platform').db)) ?? originFromRequestUrl(c.req.url) } function runtimeInfo(c: Context) { @@ -59,7 +48,7 @@ const app = new Hono() const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT const currentHost = - configuredPublicHost(c) ?? + (await configuredPublicHost(c)) ?? normalizeHost(c.req.header('x-forwarded-host') ?? c.req.header('host')) ?? new URL(c.req.url).host const state = await loadBindingState(db, { currentHost, cloudBaseUrl }) @@ -78,10 +67,9 @@ const app = new Hono() const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT - const origin = configuredPublicOrigin(c) + const origin = await getInstanceOrigin(c) const instance = origin ? await buildCloudInstanceInfo(db, { - configuredInstanceId: configuredInstanceId(c), url: origin, runtime: runtimeInfo(c), }) diff --git a/server/scheduled-worker.test.ts b/server/scheduled-worker.test.ts index 5fe7e33f..1672273f 100644 --- a/server/scheduled-worker.test.ts +++ b/server/scheduled-worker.test.ts @@ -62,9 +62,7 @@ describe('handleScheduled', () => { { cron: INSTANCE_TELEMETRY_CRON }, { DB: {} as D1Database, - BETTER_AUTH_URL: 'https://zpan.example', ZPAN_CLOUD_URL: 'https://cloud.example', - ZPAN_INSTANCE_ID: 'configured-instance', }, ) @@ -72,8 +70,6 @@ describe('handleScheduled', () => { expect(reportInstanceTelemetry).toHaveBeenCalledWith({ db: 'db', config: { - configuredInstanceId: 'configured-instance', - siteUrl: 'https://zpan.example', allowIp: true, }, cron: '0 */12 * * *', diff --git a/server/services/instance-telemetry.test.ts b/server/services/instance-telemetry.test.ts index 84391a12..8d836efc 100644 --- a/server/services/instance-telemetry.test.ts +++ b/server/services/instance-telemetry.test.ts @@ -66,7 +66,6 @@ describe('instance telemetry', () => { const result = await reportInstanceTelemetry({ db: {} as Database, config: { - configuredInstanceId: 'configured-inst', siteUrl: 'https://zpan.example.com/path', }, cron: INSTANCE_TELEMETRY_CRON, @@ -82,7 +81,7 @@ describe('instance telemetry', () => { }) expect(result).toEqual({ reported: true }) - expect(getOrCreateInstanceId).toHaveBeenCalledWith({}, 'configured-inst') + expect(getOrCreateInstanceId).toHaveBeenCalledWith({}) expect(getInstanceDisplayName).toHaveBeenCalledWith({}) expect(posthogMocks.PostHog).toHaveBeenCalledWith(INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, { host: INSTANCE_TELEMETRY_POSTHOG_HOST, @@ -180,6 +179,7 @@ describe('instance telemetry', () => { await reportInstanceTelemetry({ db: {} as Database, config: { + siteUrl: 'https://zpan.example.com', allowIp: false, }, cron: INSTANCE_TELEMETRY_CRON, diff --git a/server/services/instance-telemetry.ts b/server/services/instance-telemetry.ts index 35b03e6b..96c2b553 100644 --- a/server/services/instance-telemetry.ts +++ b/server/services/instance-telemetry.ts @@ -3,6 +3,7 @@ import packageJson from '../../package.json' import { getOrCreateInstanceId } from '../licensing/instance-id' import { getInstanceDisplayName } from '../licensing/instance-info' import type { Database } from '../platform/interface' +import { getSitePublicOrigin, normalizePublicOrigin } from './site-public-origin' export const INSTANCE_TELEMETRY_CRON = '0 */12 * * *' export const INSTANCE_TELEMETRY_EVENT = 'heartbeat' @@ -13,7 +14,6 @@ export const INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN = 'phc_uh9AB5AqnpXpFfW2Ns7 export interface InstanceTelemetryConfig { posthogHost?: string posthogProjectToken?: string - configuredInstanceId?: string siteUrl?: string allowIp?: boolean } @@ -46,9 +46,10 @@ export async function reportInstanceTelemetry(params: InstanceTelemetryParams): const posthogProjectToken = (params.config.posthogProjectToken ?? INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN).trim() if (!posthogHost || !posthogProjectToken) return { reported: false, reason: 'disabled' } - const instanceId = await getOrCreateInstanceId(params.db, params.config.configuredInstanceId) + const instanceId = await getOrCreateInstanceId(params.db) const instanceName = await getInstanceDisplayName(params.db) - const instanceUrl = normalizeSiteUrl(params.config.siteUrl) + const instanceUrl = + normalizePublicOrigin(params.config.siteUrl) ?? (await getSitePublicOrigin(params.db)) ?? undefined const timestamp = (params.now ?? new Date()).toISOString() const disableGeoip = params.config.allowIp === false const client = new PostHog(posthogProjectToken, { @@ -134,18 +135,6 @@ function addOptionalProperty(properties: Record, key: string, v if (value) properties[key] = value } -function normalizeSiteUrl(value: string | undefined): string | undefined { - const input = value?.trim() - if (!input) return undefined - try { - const url = new URL(input) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined - return url.origin - } catch { - return undefined - } -} - function compactObject(input: Record): Record { return Object.fromEntries(Object.entries(input).filter((entry) => entry[1] !== undefined)) } diff --git a/server/services/site-public-origin.ts b/server/services/site-public-origin.ts new file mode 100644 index 00000000..485e8c85 --- /dev/null +++ b/server/services/site-public-origin.ts @@ -0,0 +1,79 @@ +import { eq } from 'drizzle-orm' +import { systemOptions } from '../db/schema' +import type { Database } from '../platform/interface' + +export const SITE_PUBLIC_ORIGIN_KEY = 'site_public_origin' + +const ensuredOrigins = new WeakMap() +const ensurePromises = new WeakMap>() + +export interface EnsureSitePublicOriginResult { + origin: string | null + created: boolean +} + +export async function getSitePublicOrigin(db: Database): Promise { + const rows = await db + .select({ value: systemOptions.value }) + .from(systemOptions) + .where(eq(systemOptions.key, SITE_PUBLIC_ORIGIN_KEY)) + .limit(1) + + return normalizePublicOrigin(rows[0]?.value) +} + +export async function ensureSitePublicOrigin(db: Database, requestUrl: string): Promise { + const cached = ensuredOrigins.get(db) + if (cached) return { origin: cached, created: false } + const pending = ensurePromises.get(db) + if (pending) return pending + + const promise = ensureSitePublicOriginUncached(db, requestUrl).finally(() => { + ensurePromises.delete(db) + }) + ensurePromises.set(db, promise) + + return promise +} + +async function ensureSitePublicOriginUncached(db: Database, requestUrl: string): Promise { + const existing = await getSitePublicOrigin(db) + if (existing) { + ensuredOrigins.set(db, existing) + return { origin: existing, created: false } + } + + const origin = originFromRequestUrl(requestUrl) + if (!origin) return { origin: null, created: false } + + await db + .insert(systemOptions) + .values({ key: SITE_PUBLIC_ORIGIN_KEY, value: origin, public: false }) + .onConflictDoNothing({ target: systemOptions.key }) + + const saved = await getSitePublicOrigin(db) + if (saved) ensuredOrigins.set(db, saved) + return { origin: saved, created: saved === origin } +} + +export function originFromRequestUrl(requestUrl: string): string | null { + try { + const url = new URL(requestUrl) + return normalizePublicOrigin(url.origin) + } catch { + return null + } +} + +export function normalizePublicOrigin(value: string | undefined | null): string | null { + const input = value?.trim() + if (!input) return null + + try { + const url = new URL(input) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.origin + } catch { + return null + } +} diff --git a/server/test/setup.ts b/server/test/setup.ts index ac327ab4..70d89838 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -651,7 +651,7 @@ async function seedLicense( edition: input.edition, features: input.features, licenseId: 'test-license-unit', - authorizedHosts: ['localhost', 'zpan.example', 'auth.example.com'], + authorizedHosts: ['localhost', 'zpan.example', 'auth.example.com', 'files.example.com'], licenseValidUntil: issuedAt + 365 * 24 * 60 * 60, issuedAt, notBefore: issuedAt, diff --git a/src/hooks/use-site-options.test.ts b/src/hooks/use-site-options.test.ts index 7bd4bf4b..9a79f513 100644 --- a/src/hooks/use-site-options.test.ts +++ b/src/hooks/use-site-options.test.ts @@ -68,6 +68,7 @@ describe('useSiteOptions — option map extraction logic', () => { expect(useSiteOptions()).toEqual({ siteName: 'Custom Pan', siteDescription: 'Custom description', + sitePublicOrigin: '', defaultOrgQuota: DEFAULT_ORG_QUOTA, authSignupMode: SignupMode.CLOSED, captchaEnabled: true, @@ -90,6 +91,7 @@ describe('useSiteOptions — option map extraction logic', () => { expect(useSiteOptions()).toEqual({ siteName: DEFAULT_SITE_NAME, siteDescription: DEFAULT_SITE_DESCRIPTION, + sitePublicOrigin: '', defaultOrgQuota: DEFAULT_ORG_QUOTA, authSignupMode: SignupMode.OPEN, captchaEnabled: false, diff --git a/src/hooks/use-site-options.ts b/src/hooks/use-site-options.ts index 4e663f63..0ed69398 100644 --- a/src/hooks/use-site-options.ts +++ b/src/hooks/use-site-options.ts @@ -33,6 +33,7 @@ export function useSiteOptions() { return { siteName: optionMap.get('site_name') ?? DEFAULT_SITE_NAME, siteDescription: optionMap.get('site_description') ?? DEFAULT_SITE_DESCRIPTION, + sitePublicOrigin: optionMap.get('site_public_origin') ?? '', defaultOrgQuota: resolveDefaultOrgQuotaValue(optionMap.get('default_org_quota')), authSignupMode: (optionMap.get('auth_signup_mode') as SignupMode) ?? SignupMode.OPEN, captchaEnabled: optionMap.get(CAPTCHA_ENABLED_KEY) === 'true', diff --git a/src/routes/_authenticated/admin/settings/index.test.tsx b/src/routes/_authenticated/admin/settings/index.test.tsx index eeec6e44..29790b38 100644 --- a/src/routes/_authenticated/admin/settings/index.test.tsx +++ b/src/routes/_authenticated/admin/settings/index.test.tsx @@ -17,6 +17,7 @@ const siteOptionsState = vi.hoisted(() => ({ current: { siteName: 'ZPan', siteDescription: 'File hosting', + sitePublicOrigin: 'https://zpan.example.com', defaultOrgQuota: 1073741824, authSignupMode: 'open', captchaEnabled: false, @@ -97,6 +98,7 @@ afterEach(() => { siteOptionsState.current = { siteName: 'ZPan', siteDescription: 'File hosting', + sitePublicOrigin: 'https://zpan.example.com', defaultOrgQuota: 1073741824, authSignupMode: SignupMode.OPEN, captchaEnabled: false, @@ -120,11 +122,15 @@ describe('SettingsPage', () => { fireEvent.change(view.getByLabelText('admin.settings.siteDescription'), { target: { value: 'Updated file hosting' }, }) + fireEvent.change(view.getByLabelText('admin.settings.sitePublicOrigin'), { + target: { value: 'https://new.example.com/path' }, + }) fireEvent.click(view.getAllByRole('button', { name: 'common.save' })[0]) await waitFor(() => expect(setSystemOption).toHaveBeenCalledWith('site_name', 'New ZPan', true)) expect(setSystemOption).toHaveBeenCalledWith('site_description', 'Updated file hosting', true) + expect(setSystemOption).toHaveBeenCalledWith('site_public_origin', 'https://new.example.com/path', false) expect(toast.success).toHaveBeenCalledWith('admin.settings.saved') }) diff --git a/src/routes/_authenticated/admin/settings/index.tsx b/src/routes/_authenticated/admin/settings/index.tsx index 6211f9f6..9be40d9a 100644 --- a/src/routes/_authenticated/admin/settings/index.tsx +++ b/src/routes/_authenticated/admin/settings/index.tsx @@ -45,6 +45,10 @@ function bytesToDisplay(bytes: number): { value: number; unit: StorageQuotaUnit const settingsSchema = z.object({ siteName: z.string().min(1), siteDescription: z.string(), + sitePublicOrigin: z + .string() + .trim() + .refine((value) => value === '' || /^https?:\/\/[^/]+/.test(value), 'Site URL must start with http:// or https://'), quotaValue: z.coerce.number().positive('Quota must be a positive number'), quotaUnit: z.enum(['MB', 'GB']), registrationsEnabled: z.boolean(), @@ -75,6 +79,7 @@ export function SettingsPage() { const { siteName, siteDescription, + sitePublicOrigin, defaultOrgQuota: quotaBytes, authSignupMode, captchaEnabled, @@ -93,6 +98,7 @@ export function SettingsPage() { defaultValues: { siteName: '', siteDescription: '', + sitePublicOrigin: '', quotaValue: 0, quotaUnit: 'MB', registrationsEnabled: false, @@ -110,6 +116,7 @@ export function SettingsPage() { form.reset({ siteName, siteDescription, + sitePublicOrigin, quotaValue: value, quotaUnit: unit, registrationsEnabled: authSignupMode === SignupMode.OPEN, @@ -123,6 +130,7 @@ export function SettingsPage() { isLoading, siteName, siteDescription, + sitePublicOrigin, quotaBytes, authSignupMode, captchaEnabled, @@ -135,11 +143,12 @@ export function SettingsPage() { const identityMutation = useMutation({ mutationFn: async () => { - const valid = await form.trigger(['siteName', 'siteDescription']) + const valid = await form.trigger(['siteName', 'siteDescription', 'sitePublicOrigin']) if (!valid) throw new Error(t('admin.settings.identityInvalid')) const values = form.getValues() await setSystemOption('site_name', values.siteName, true) await setSystemOption('site_description', values.siteDescription, true) + await setSystemOption('site_public_origin', values.sitePublicOrigin.trim(), false) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: siteOptionsQueryKey }) @@ -268,6 +277,19 @@ export function SettingsPage() { )} +
+ + +

{t('admin.settings.sitePublicOriginHint')}

+ {form.formState.errors.sitePublicOrigin && ( +

{form.formState.errors.sitePublicOrigin.message}

+ )} +
+