mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
196 lines
6.3 KiB
TypeScript
196 lines
6.3 KiB
TypeScript
import { eq } from 'drizzle-orm'
|
|
import { Hono } from 'hono'
|
|
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
|
import { systemOptions } from '../db/schema'
|
|
import { invalidateEntitlementCache } from '../licensing/entitlement'
|
|
import { getOrCreateInstanceId } from '../licensing/instance-id'
|
|
import { clearLicenseBinding, createLicenseBinding, loadLicenseState } from '../licensing/license-state'
|
|
import { performRefresh } from '../licensing/refresh'
|
|
import { normalizeHost, verifyCertificate } from '../licensing/verify'
|
|
import { requireAdmin } from '../middleware/auth'
|
|
import type { Env } from '../middleware/platform'
|
|
import { recordActivity } from '../services/activity'
|
|
import { createPairing, pollPairing, unbindCloudLicense } from '../services/licensing-cloud'
|
|
|
|
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 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 getInstanceOrigin(c: {
|
|
get(key: 'platform'): { getEnv(k: string): string | undefined }
|
|
req: { url: string; header(name: string): string | undefined }
|
|
}): string {
|
|
const configured = configuredPublicOrigin(c)
|
|
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
|
|
}
|
|
|
|
function getRequestHost(c: {
|
|
get(key: 'platform'): { getEnv(k: string): string | undefined }
|
|
req: { url: string; header(name: string): string | undefined }
|
|
}): string {
|
|
const configured = configuredPublicOrigin(c)
|
|
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
|
|
}
|
|
|
|
const app = new Hono<Env>()
|
|
.use(requireAdmin)
|
|
|
|
.post('/pair', async (c) => {
|
|
const db = c.get('platform').db
|
|
const baseUrl = getCloudBaseUrl(c)
|
|
|
|
const instanceId = await getOrCreateInstanceId(db)
|
|
|
|
const titleRows = await db
|
|
.select({ value: systemOptions.value })
|
|
.from(systemOptions)
|
|
.where(eq(systemOptions.key, 'site_title'))
|
|
.limit(1)
|
|
|
|
const instanceName = titleRows[0]?.value ?? 'ZPan'
|
|
const instanceHost = getInstanceOrigin(c)
|
|
|
|
const pairing = await createPairing(baseUrl, instanceId, instanceName, instanceHost)
|
|
return c.json(pairing)
|
|
})
|
|
|
|
.get('/pair/:code/poll', async (c) => {
|
|
const { code } = c.req.param()
|
|
const db = c.get('platform').db
|
|
const baseUrl = getCloudBaseUrl(c)
|
|
|
|
const result = await pollPairing(baseUrl, code)
|
|
|
|
if (result.status === 'approved' && result.refreshToken && result.certificate) {
|
|
const entitlement = {
|
|
refreshToken: result.refreshToken,
|
|
certificate: result.certificate,
|
|
binding: result.binding,
|
|
account: result.account,
|
|
}
|
|
const instanceId = await getOrCreateInstanceId(db)
|
|
const cert = entitlement.certificate
|
|
const assertion = verifyCertificate(cert, {
|
|
instanceId,
|
|
currentHost: getRequestHost(c),
|
|
cloudBaseUrl: baseUrl,
|
|
})
|
|
if (!assertion || !entitlement.binding?.storeId || !entitlement.account) {
|
|
return c.json({ error: 'invalid_certificate' }, 502)
|
|
}
|
|
|
|
await createLicenseBinding(db, {
|
|
cloudBindingId: entitlement.binding.id,
|
|
cloudStoreId: entitlement.binding.storeId,
|
|
instanceId,
|
|
cloudAccountId: entitlement.account.id,
|
|
cloudAccountEmail: entitlement.account.email,
|
|
refreshToken: entitlement.refreshToken,
|
|
cachedCert: cert,
|
|
cachedExpiresAt: assertion.expiresAt,
|
|
lastRefreshAt: Math.floor(Date.now() / 1000),
|
|
})
|
|
|
|
invalidateEntitlementCache()
|
|
|
|
const userId = c.get('userId')!
|
|
const orgId = c.get('orgId')!
|
|
await recordActivity(db, {
|
|
orgId,
|
|
userId,
|
|
action: 'license_pair',
|
|
targetType: 'license',
|
|
targetName: entitlement.account.email ?? entitlement.account.id,
|
|
metadata: { edition: assertion.edition, cloudAccountId: entitlement.account.id },
|
|
})
|
|
|
|
return c.json({
|
|
status: 'approved' as const,
|
|
edition: assertion.edition,
|
|
})
|
|
}
|
|
|
|
if (result.status === 'approved') {
|
|
return c.json({ error: 'invalid_pairing_response' }, 502)
|
|
}
|
|
|
|
return c.json({ status: result.status })
|
|
})
|
|
|
|
.post('/refresh', async (c) => {
|
|
const db = c.get('platform').db
|
|
const userId = c.get('userId')!
|
|
const orgId = c.get('orgId')!
|
|
const baseUrl = getCloudBaseUrl(c)
|
|
|
|
await performRefresh(db, baseUrl)
|
|
|
|
const state = await loadLicenseState(db)
|
|
|
|
await recordActivity(db, {
|
|
orgId,
|
|
userId,
|
|
action: 'license_refresh',
|
|
targetType: 'license',
|
|
targetName: 'license binding',
|
|
})
|
|
|
|
return c.json({ success: true, last_refresh_at: state.lastRefreshAt })
|
|
})
|
|
|
|
.delete('/binding', async (c) => {
|
|
const db = c.get('platform').db
|
|
const userId = c.get('userId')!
|
|
const orgId = c.get('orgId')!
|
|
const baseUrl = getCloudBaseUrl(c)
|
|
const state = await loadLicenseState(db)
|
|
let cloudUnbindError: string | null = null
|
|
|
|
if (state.refreshToken) {
|
|
try {
|
|
await unbindCloudLicense(baseUrl, state.cloudBindingId, state.refreshToken)
|
|
} catch (error) {
|
|
cloudUnbindError = error instanceof Error ? error.message : 'Cloud unbind failed'
|
|
}
|
|
}
|
|
|
|
await clearLicenseBinding(db)
|
|
invalidateEntitlementCache()
|
|
|
|
await recordActivity(db, {
|
|
orgId,
|
|
userId,
|
|
action: 'license_disconnect',
|
|
targetType: 'license',
|
|
targetName: 'license binding',
|
|
metadata: cloudUnbindError ? { cloudUnbindError } : undefined,
|
|
})
|
|
|
|
return c.json({ deleted: true, cloud_unbind_error: cloudUnbindError })
|
|
})
|
|
|
|
export default app
|