初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
/**
* Fastify bootstrap: assembles the HTTP server, registers plugins and routes,
* and owns the DB lifecycle via the close hook.
* @module dshs/web/server
*/
import Fastify, { type FastifyInstance } from 'fastify'
import fastifyStatic from '@fastify/static'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import type { ServerConfig } from '../config.js'
import { createDbAdapter, type DbAdapter, type PublicUser } from '../db/index.js'
import { createUserFs } from '../fs/provider.js'
import type { UserFs } from '../fs/user-fs.js'
import { decrypt, deriveKey } from '../crypto.js'
import { hashUid } from '../isolation.js'
import { LocalSpawner } from '../supervisor/orchestrator.js'
import { K8sSpawner } from '../supervisor/k8s-spawner.js'
import { registerDshProxy } from '../supervisor/proxy.js'
import type { Spawner } from '../supervisor/spawner.js'
import { rateLimit } from './middleware/rate-limit.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { businessPluginRoutes } from './routes/business-plugins.js'
import { desktopRoutes } from './routes/desktop.js'
import { dshRoutes } from './routes/dsh.js'
import { domainRoutes } from './routes/domain.js'
import { skillRoutes } from './routes/skills.js'
import { whitelistRoutes } from './routes/whitelist.js'
declare module 'fastify' {
interface FastifyInstance {
db: DbAdapter
config: ServerConfig
supervisor: Spawner
userFs: UserFs
}
interface FastifyRequest {
user: PublicUser | null
}
}
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '../../web')
/** Whether an `Origin` header belongs to the platform base domain (or a
* per-user subdomain of it). Used to allow cross-subdomain API calls from dsh
* instances (功能插件启停). */
function isAllowedOrigin(origin: string, baseDomain: string): boolean {
if (baseDomain === '') return false
try {
const host = new URL(origin).hostname
return host === baseDomain || host.endsWith('.' + baseDomain)
} catch {
return false
}
}
/**
* Build a fully-wired Fastify instance. Does not call `listen`; the caller owns
* bind + shutdown.
* @param config - resolved runtime configuration.
*/
export async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
const db = await createDbAdapter(config)
const encryptionKey = deriveKey(config.encryptionSecret)
const resolveApiKey = async (_userId: string): Promise<string | null> => {
// 统一 KEY 模式:所有用户共用管理员(admin)设置的启用 key,用户不可自配。
// 忽略入参 userId——不管哪个用户 spawn,都注入同一把管理员 key。
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0) return null
const ref = await db.getEnabledCredentialKeyRef(admins[0].id)
if (ref === null) return null
try {
return decrypt(ref, encryptionKey)
} catch {
return null // corrupt ref — treat as unset, let the admin re-enter it
}
}
const resolveUid = async (userId: string): Promise<number> => {
const user = await db.findUserById(userId)
return user?.uid ?? hashUid(userId, config.baseUid)
}
const supervisor: Spawner =
config.deployMode === 'k8s'
? new K8sSpawner(config, db, resolveApiKey, resolveUid)
: new LocalSpawner(config, resolveApiKey, resolveUid)
const userFs = createUserFs(config, (userId) => supervisor.ensureFileService(userId))
const app = Fastify({
logger: { level: config.logLevel },
trustProxy: true,
bodyLimit: config.maxUploadBytes,
})
app.decorate('db', db)
app.decorate('config', config)
app.decorate('supervisor', supervisor)
app.decorate('userFs', userFs)
app.decorateRequest('user', null)
// Reverse proxy (subdomain + legacy subpath). Registered first so its global
// onRequest hook intercepts per-user subdomain traffic before other hooks.
await registerDshProxy(app)
// CORS for cross-subdomain API calls from dsh instances (功能插件启停 section
// runs in the browser on `<user>.dsh.alotbuy.com` and calls portal APIs on
// `dsh.alotbuy.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
// Domain=.dsh.alotbuy.com, so credentials ride along; we only need to allow
// the Origin. Restricted to the platform base domain and its subdomains.
app.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin
if (origin === undefined || origin === '') return
if (!isAllowedOrigin(origin, config.baseDomain)) return
reply.header('Access-Control-Allow-Origin', origin)
reply.header('Access-Control-Allow-Credentials', 'true')
reply.header('Vary', 'Origin')
if (request.raw.method === 'OPTIONS') {
reply.header('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS')
reply.header('Access-Control-Allow-Headers', 'Content-Type')
reply.header('Access-Control-Max-Age', '600')
return reply.code(204).send()
}
})
app.addHook('onClose', async () => {
await supervisor.teardown()
await db.close()
})
// Rate limiting first so auth/admin surfaces are covered by default.
await app.register(rateLimit)
// Domain-specific route groups (API).
await app.register(authRoutes)
await app.register(adminRoutes)
await app.register(businessPluginRoutes)
await app.register(desktopRoutes)
await app.register(dshRoutes)
await app.register(domainRoutes)
await app.register(skillRoutes)
await app.register(whitelistRoutes)
// Static placeholder SPA last, so exact API routes take precedence over the
// wildcard static handler.
await app.register(fastifyStatic, {
root: webRoot,
prefix: '/',
wildcard: true,
index: ['index.html'],
})
return app
}