app.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { randomBytes, createHash } from 'node:crypto'
  2. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  3. import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
  4. import { createServer as createHttpsServer } from 'node:https'
  5. import { join } from 'node:path'
  6. import { bodyParser } from '@koa/bodyparser'
  7. import Koa from 'koa'
  8. import { RealtimeAPI } from './realtimeApi.js'
  9. import type { ServerConfig } from './config.js'
  10. import { loadServerConfig } from './config.js'
  11. import { errorMessage, HttpError } from './errors.js'
  12. import { NodePairingStore } from './pairing.js'
  13. import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
  14. import { AccountLoginPairingStore } from './accountPairing.js'
  15. import { UpstreamProfileIdentityService } from './profileIdentities.js'
  16. import { createApiRouter } from './routes.js'
  17. import {
  18. applySecurityHeaders,
  19. CsrfProtection,
  20. isAllowedHostHeader,
  21. isExactOrigin,
  22. } from './security.js'
  23. import { UpstreamHttpError, UpstreamClient } from './upstream.js'
  24. import { UploadStore } from './uploads.js'
  25. import { WorkspaceStore } from './workspaceStore.js'
  26. import { WorkspaceNodes } from './workspaceGateway.js'
  27. import { WorkspaceRuntime } from './workspaceRuntime.js'
  28. import { WorkspaceAssets } from './workspaceAssets.js'
  29. import { workspaceRouter } from './workspaceRoutes.js'
  30. import { SystemUpdateManager } from './updateManager.js'
  31. import { PushCoordinator } from './pushCoordinator.js'
  32. import {
  33. APNsConfigurationManager,
  34. type APNsConfigurationSnapshot,
  35. } from './apnsConfiguration.js'
  36. import {
  37. FCMConfigurationManager,
  38. type FCMConfigurationSnapshot,
  39. } from './fcmConfiguration.js'
  40. import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
  41. import {
  42. AllowedHostsConfigurationManager,
  43. type AllowedHostsConfigurationSnapshot,
  44. } from './allowedHostsConfiguration.js'
  45. import {
  46. ChatPushJobManager,
  47. HermesChatNotificationResolver,
  48. } from './pushEvents.js'
  49. export interface ApplicationOptions {
  50. config?: ServerConfig
  51. fetchImpl?: typeof fetch
  52. csrfSecret?: Buffer
  53. pairings?: NodePairingStore
  54. uploads?: UploadStore
  55. updates?: SystemUpdateManager
  56. auth?: LocalAuthStore
  57. upstreamSession?: UpstreamServiceSession
  58. accountPairings?: AccountLoginPairingStore
  59. profileIdentities?: UpstreamProfileIdentityService
  60. push?: PushCoordinator
  61. apnsConfiguration?: APNsConfigurationManager
  62. fcmConfiguration?: FCMConfigurationManager
  63. allowedHostsConfiguration?: AllowedHostsConfigurationManager
  64. }
  65. export interface ApplicationRuntime {
  66. workspace: WorkspaceStore
  67. workspaceRuntime: WorkspaceRuntime
  68. realtime: RealtimeAPI
  69. app: Koa
  70. config: ServerConfig
  71. csrf: CsrfProtection
  72. pairings: NodePairingStore
  73. upstream: UpstreamClient
  74. uploads: UploadStore
  75. updates: SystemUpdateManager
  76. auth: LocalAuthStore
  77. upstreamSession: UpstreamServiceSession
  78. accountPairings: AccountLoginPairingStore
  79. profileIdentities: UpstreamProfileIdentityService
  80. push: PushCoordinator
  81. apnsConfiguration: APNsConfigurationManager
  82. fcmConfiguration: FCMConfigurationManager
  83. allowedHostsConfiguration: AllowedHostsConfigurationManager
  84. pushEventCoordinator: PushCoordinatorEventAdapter
  85. chatPushJobs: ChatPushJobManager
  86. close(): void
  87. }
  88. function isMutation(method: string): boolean {
  89. return !['GET', 'HEAD', 'OPTIONS'].includes(method)
  90. }
  91. function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
  92. if (override) return override
  93. const path = join(home, 'csrf-secret.bin')
  94. try {
  95. const secret = readFileSync(path)
  96. if (secret.byteLength === 32) return secret
  97. throw new Error('CSRF secret has an invalid length')
  98. } catch (error) {
  99. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  100. }
  101. mkdirSync(home, { recursive: true, mode: 0o700 })
  102. const generated = randomBytes(32)
  103. try {
  104. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  105. return generated
  106. } catch (error) {
  107. // Another 8800 process can finish first during launchd hand-off. Never
  108. // rotate its secret; use the one it safely wrote instead.
  109. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  110. throw error
  111. }
  112. }
  113. export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
  114. const config = options.config ?? loadServerConfig()
  115. const runtimeAllowedHosts = new Set(config.allowedHosts)
  116. config.allowedHosts = runtimeAllowedHosts
  117. const app = new Koa()
  118. app.proxy = false
  119. const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
  120. const pairings = options.pairings ?? new NodePairingStore(config.home)
  121. const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
  122. const uploads = options.uploads ?? new UploadStore(config.home)
  123. const updates = options.updates ?? new SystemUpdateManager(config)
  124. const auth = options.auth ?? new LocalAuthStore(config.home, Boolean(config.tlsCert))
  125. const configuredUpstreamCredentials = config.upstreamUsername && config.upstreamPassword
  126. ? { username: config.upstreamUsername, password: config.upstreamPassword }
  127. : undefined
  128. const upstreamSession = options.upstreamSession ?? new UpstreamServiceSession(
  129. upstream,
  130. () => auth.upstreamCredentials(configuredUpstreamCredentials),
  131. )
  132. const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
  133. const profileIdentities = options.profileIdentities
  134. ?? new UpstreamProfileIdentityService(config, upstreamSession)
  135. const initialAPNsSettings: APNsConfigurationSnapshot = config.apnsSettings ?? {
  136. source: config.apns || config.apnsConfigurationError ? 'environment' : 'none',
  137. editable: !(config.apns || config.apnsConfigurationError),
  138. ...(config.apns ? {
  139. input: {
  140. ...config.apns,
  141. environments: [...(config.apns.environments ?? ['development', 'production'])],
  142. },
  143. config: config.apns,
  144. } : {}),
  145. warnings: [],
  146. ...(config.apnsConfigurationError ? { configurationError: config.apnsConfigurationError } : {}),
  147. }
  148. const apnsConfiguration = options.apnsConfiguration
  149. ?? new APNsConfigurationManager(config.home, initialAPNsSettings)
  150. const initialFCMSettings: FCMConfigurationSnapshot = config.fcmSettings ?? {
  151. source: config.fcm || config.fcmConfigurationError ? 'environment' : 'none',
  152. editable: !(config.fcm || config.fcmConfigurationError),
  153. ...(config.fcm ? { input: { ...config.fcm }, config: config.fcm } : {}),
  154. warnings: [],
  155. ...(config.fcmConfigurationError ? { configurationError: config.fcmConfigurationError } : {}),
  156. }
  157. const fcmConfiguration = options.fcmConfiguration
  158. ?? new FCMConfigurationManager(config.home, initialFCMSettings)
  159. const initialAllowedHostsSettings: AllowedHostsConfigurationSnapshot = config.allowedHostsSettings ?? {
  160. source: runtimeAllowedHosts.size ? 'environment' : 'none',
  161. hosts: [...runtimeAllowedHosts],
  162. editableHosts: [],
  163. environmentHosts: [...runtimeAllowedHosts],
  164. }
  165. const allowedHostsConfiguration = options.allowedHostsConfiguration
  166. ?? new AllowedHostsConfigurationManager(config.home, initialAllowedHostsSettings, runtimeAllowedHosts)
  167. const push = options.push ?? new PushCoordinator({
  168. home: config.home,
  169. apns: config.apns,
  170. apnsConfigurationError: config.apnsConfigurationError,
  171. fcm: config.fcm,
  172. fcmConfigurationError: config.fcmConfigurationError,
  173. isUserActive: userID => auth.isUserActive(userID),
  174. userAuthorizationVersion: userID => auth.pushAuthorizationVersion(userID),
  175. })
  176. const pushEventCoordinator = new PushCoordinatorEventAdapter(push)
  177. const chatPushJobs = new ChatPushJobManager(config, upstreamSession, pushEventCoordinator, {
  178. resolver: new HermesChatNotificationResolver(
  179. upstreamSession,
  180. (localUserID, prompt) => push.promptDigest(localUserID, prompt),
  181. ),
  182. })
  183. // The application owns group events. No Dashboard plugin is queried.
  184. const workspace = new WorkspaceStore(config.home)
  185. const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession })
  186. const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner))
  187. const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
  188. workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
  189. workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
  190. if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return
  191. push.enqueueNotification({ kind: interaction ? interaction.kind === 'approval' ? 'chat.approval.requested' : 'chat.clarification.requested' : run.status === 'failed' ? 'chat.failed' : 'chat.completed', eventID: `workspace:${interaction?.id ?? run.id}`, localUserID: owner, title: c.name, body: (interaction?.message || message?.content || run.error || '回复完成').slice(0,180), collapseID: `workspace:${c.id}`, data: { conversationId:c.id, workspace:'1' }, ...(c.kind === 'group' ? {roomID:c.id} : {}) })
  192. }
  193. const realtime = new RealtimeAPI(config, auth, csrf, pairings, upstream, upstreamSession, {
  194. coordinator: pushEventCoordinator,
  195. resolver: new HermesChatNotificationResolver(upstreamSession, (user, prompt) => push.promptDigest(user, prompt)),
  196. })
  197. realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
  198. realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
  199. if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
  200. const data = frame.payload ?? {}, text = JSON.stringify(data)
  201. const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
  202. void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
  203. }
  204. chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
  205. try {
  206. uploads.cleanupUncommitted()
  207. } catch {
  208. // A locked cleanup should not prevent startup; upload operations will
  209. // still surface the underlying storage error when used.
  210. }
  211. app.use(async (ctx, next) => {
  212. try {
  213. await next()
  214. } catch (error) {
  215. if (error instanceof UpstreamHttpError) {
  216. ctx.status = error.response.status
  217. ctx.type = 'application/json; charset=utf-8'
  218. ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
  219. return
  220. }
  221. const status = error instanceof HttpError
  222. ? error.status
  223. : typeof (error as { status?: unknown })?.status === 'number'
  224. ? Number((error as { status: number }).status)
  225. : 500
  226. const expose = status < 500 || error instanceof HttpError
  227. ctx.status = status
  228. ctx.type = 'application/json; charset=utf-8'
  229. ctx.body = {
  230. error: expose ? errorMessage(error) : 'Internal server error',
  231. code: error instanceof HttpError ? error.code : 'internal_error',
  232. }
  233. if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
  234. }
  235. })
  236. app.use(async (ctx, next) => {
  237. applySecurityHeaders(ctx, Boolean(config.tlsCert), config.allowedHosts)
  238. if (!isAllowedHostHeader(ctx.get('host'), config)) {
  239. throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
  240. }
  241. await next()
  242. })
  243. app.use(async (ctx, next) => {
  244. if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
  245. const secure = ctx.secure || Boolean(config.tlsCert)
  246. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
  247. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  248. }
  249. if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  250. throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
  251. }
  252. }
  253. await next()
  254. })
  255. app.use(realtime.middleware())
  256. const parseBody = (limit: string) => bodyParser({
  257. encoding: 'utf-8',
  258. enableTypes: ['json'],
  259. parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  260. jsonLimit: limit,
  261. onError: (error, ctx) => {
  262. throw new HttpError(ctx.status === 413 || (error as { status?: number }).status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
  263. },
  264. })
  265. const ordinaryBody = parseBody('2mb'), workspaceBody = parseBody('4mb')
  266. app.use((ctx,next) => (/^\/api\/app\/(agents|conversations)(\/|$)/.test(ctx.path) ? workspaceBody : ordinaryBody)(ctx,next))
  267. app.use(async (ctx, next) => {
  268. ctx.state.localAuth = auth
  269. ctx.state.upstreamSession = upstreamSession
  270. const anonymous = ctx.path === '/api/app/bootstrap'
  271. || ctx.path === '/api/app/login'
  272. || ctx.path === '/api/status'
  273. || ctx.path === '/api/auth/providers'
  274. || ctx.path === '/auth/password-login'
  275. || ctx.path.startsWith('/api/pair/v1/')
  276. || ctx.path.startsWith('/api/account-pair/v1/')
  277. || ctx.path === '/healthz'
  278. || ctx.path === '/readyz'
  279. if (ctx.path.startsWith('/api/app/') && !anonymous) {
  280. const allowPasswordChange = ctx.path === '/api/app/account/credentials'
  281. || ctx.path === '/api/app/logout'
  282. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  283. const adminOnly = ctx.path.startsWith('/api/app/admin/')
  284. || ctx.path.startsWith('/api/app/system/')
  285. || ctx.path.startsWith('/api/app/plugins/')
  286. || ctx.path.startsWith('/api/app/pairings')
  287. || ctx.path.startsWith('/api/app/paired-devices')
  288. || ctx.path.startsWith('/api/app/groups/nodes')
  289. if (adminOnly) ctx.state.localUser = auth.requireAdmin(ctx)
  290. }
  291. const gatewayCompatibility = (ctx.path.startsWith('/api/') && !ctx.path.startsWith('/api/app/')
  292. && !ctx.path.startsWith('/api/pair/'))
  293. || ctx.path === '/auth/logout'
  294. if (gatewayCompatibility && !anonymous) {
  295. const allowPasswordChange = ctx.path === '/api/auth/me' || ctx.path === '/api/profiles'
  296. || ctx.path === '/api/account/credentials'
  297. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  298. if (isMutation(ctx.method)) {
  299. const origin = ctx.get('origin')
  300. if (origin && !isExactOrigin(origin, ctx.get('host'), ctx.secure || Boolean(config.tlsCert), config.allowedHosts)) {
  301. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  302. }
  303. }
  304. }
  305. const user = ctx.state.localUser as { id: string } | undefined
  306. if (user) {
  307. await upstream.withReadScope(`user:${user.id}:${auth.pushAuthorizationVersion(user.id)}`, ctx.get('x-yaoyao-cache') === 'bypass', next)
  308. } else await next()
  309. })
  310. const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
  311. app.use(applicationRouter.routes())
  312. app.use(async (ctx, next) => {
  313. if (ctx.path.includes('/plugins/yaoyao') || ctx.path.startsWith('/api/app/groups')) throw new HttpError(410, '请使用新版聊天接口', 'retired_plugin_api')
  314. const id = /\/sessions\/([^/]+)/.exec(ctx.path)?.[1]
  315. if (id && workspace.ownsUpstream(decodeURIComponent(id))) throw new HttpError(404, '会话不存在', 'session_not_found')
  316. await next()
  317. // Native history is a separate surface, including search and profile projections.
  318. if (ctx.path.includes('/sessions') || ctx.path.endsWith('/profiles')) {
  319. let value: any = ctx.body
  320. const buffered = Buffer.isBuffer(value)
  321. try {
  322. if (buffered) value = JSON.parse(value.toString())
  323. const filter = (v: any): any => {
  324. if (Array.isArray(v)) return v.filter(x => !x || typeof x !== 'object' || (x.source !== 'yaoyao_workspace' && !workspace.ownsUpstream(String(x.id ?? x.session_id ?? x.sessionId ?? '')))).map(filter)
  325. if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k,x]) => [k, filter(x)]))
  326. return v
  327. }
  328. ctx.body = buffered ? Buffer.from(JSON.stringify(filter(value))) : filter(value)
  329. } catch { /* Preserve non-JSON error responses. */ }
  330. }
  331. })
  332. const router = createApiRouter({
  333. workspace,
  334. config,
  335. csrf,
  336. upstream,
  337. pairings,
  338. uploads,
  339. updates,
  340. auth,
  341. upstreamSession,
  342. accountPairings,
  343. profileIdentities,
  344. push,
  345. apnsConfiguration,
  346. fcmConfiguration,
  347. allowedHostsConfiguration,
  348. })
  349. app.use(router.routes())
  350. app.use(router.allowedMethods({ throw: false }))
  351. app.use(async (ctx, next) => {
  352. if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
  353. ctx.status = 404
  354. ctx.type = 'application/json; charset=utf-8'
  355. ctx.body = { error: 'Route not found', code: 'not_found' }
  356. return
  357. }
  358. await next()
  359. })
  360. return {
  361. workspace,
  362. workspaceRuntime,
  363. realtime,
  364. app,
  365. config,
  366. csrf,
  367. pairings,
  368. upstream,
  369. uploads,
  370. updates,
  371. auth,
  372. upstreamSession,
  373. accountPairings,
  374. profileIdentities,
  375. push,
  376. apnsConfiguration,
  377. fcmConfiguration,
  378. allowedHostsConfiguration,
  379. pushEventCoordinator,
  380. chatPushJobs,
  381. close: () => {
  382. workspaceAssets.close()
  383. workspaceRuntime.close()
  384. workspaceNodes.close()
  385. upstream.close()
  386. realtime.close()
  387. chatPushJobs.stop()
  388. push.close()
  389. uploads.close()
  390. workspace.close()
  391. },
  392. }
  393. }
  394. export interface NodeServerRuntime {
  395. server: HttpServer
  396. close(): Promise<void>
  397. }
  398. export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
  399. const { config } = runtime
  400. const server: HttpServer = config.tlsCert && config.tlsKey
  401. ? createHttpsServer({
  402. cert: readFileSync(config.tlsCert),
  403. key: readFileSync(config.tlsKey),
  404. }, runtime.app.callback())
  405. : createHttpServer(runtime.app.callback())
  406. const synchronizePushObservers = (enabled = runtime.push.isAnyProviderEnabled()) => {
  407. if (enabled) {
  408. runtime.chatPushJobs.start()
  409. } else {
  410. runtime.chatPushJobs.stop()
  411. }
  412. }
  413. runtime.workspaceRuntime.start()
  414. synchronizePushObservers()
  415. const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
  416. const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
  417. return {
  418. server,
  419. close: async () => {
  420. removePushConfigurationListener()
  421. removeWebSockets()
  422. runtime.realtime.close()
  423. await new Promise<void>((resolve, reject) => {
  424. if (!server.listening) {
  425. resolve()
  426. return
  427. }
  428. server.close((error) => error ? reject(error) : resolve())
  429. })
  430. runtime.close()
  431. },
  432. }
  433. }