app.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { randomBytes } 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 type { ServerConfig } from './config.js'
  9. import { loadServerConfig } from './config.js'
  10. import { errorMessage, HttpError } from './errors.js'
  11. import { RealtimeLeaseStore } from './leases.js'
  12. import { createApiRouter } from './routes.js'
  13. import {
  14. applySecurityHeaders,
  15. CsrfProtection,
  16. isAllowedHostHeader,
  17. isExactOrigin,
  18. } from './security.js'
  19. import { UpstreamHttpError, UpstreamClient } from './upstream.js'
  20. import { UploadStore } from './uploads.js'
  21. import { installWebSocketRelay } from './websocket.js'
  22. export interface ApplicationOptions {
  23. config?: ServerConfig
  24. fetchImpl?: typeof fetch
  25. csrfSecret?: Buffer
  26. leases?: RealtimeLeaseStore
  27. uploads?: UploadStore
  28. }
  29. export interface ApplicationRuntime {
  30. app: Koa
  31. config: ServerConfig
  32. csrf: CsrfProtection
  33. leases: RealtimeLeaseStore
  34. upstream: UpstreamClient
  35. uploads: UploadStore
  36. close(): void
  37. }
  38. function isMutation(method: string): boolean {
  39. return !['GET', 'HEAD', 'OPTIONS'].includes(method)
  40. }
  41. function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
  42. if (override) return override
  43. const path = join(home, 'csrf-secret.bin')
  44. try {
  45. const secret = readFileSync(path)
  46. if (secret.byteLength === 32) return secret
  47. throw new Error('CSRF secret has an invalid length')
  48. } catch (error) {
  49. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  50. }
  51. mkdirSync(home, { recursive: true, mode: 0o700 })
  52. const generated = randomBytes(32)
  53. try {
  54. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  55. return generated
  56. } catch (error) {
  57. // Another 8800 process can finish first during launchd hand-off. Never
  58. // rotate its secret; use the one it safely wrote instead.
  59. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  60. throw error
  61. }
  62. }
  63. export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
  64. const config = options.config ?? loadServerConfig()
  65. const app = new Koa()
  66. app.proxy = false
  67. const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
  68. const leases = options.leases ?? new RealtimeLeaseStore()
  69. const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
  70. const uploads = options.uploads ?? new UploadStore(config.home)
  71. try {
  72. uploads.cleanupUncommitted()
  73. } catch {
  74. // A locked cleanup should not prevent startup; upload operations will
  75. // still surface the underlying storage error when used.
  76. }
  77. app.use(async (ctx, next) => {
  78. try {
  79. await next()
  80. } catch (error) {
  81. if (error instanceof UpstreamHttpError) {
  82. ctx.status = error.response.status
  83. ctx.type = 'application/json; charset=utf-8'
  84. ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
  85. return
  86. }
  87. const status = error instanceof HttpError
  88. ? error.status
  89. : typeof (error as { status?: unknown })?.status === 'number'
  90. ? Number((error as { status: number }).status)
  91. : 500
  92. const expose = status < 500 || error instanceof HttpError
  93. ctx.status = status
  94. ctx.type = 'application/json; charset=utf-8'
  95. ctx.body = {
  96. error: expose ? errorMessage(error) : 'Internal server error',
  97. code: error instanceof HttpError ? error.code : 'internal_error',
  98. }
  99. if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
  100. }
  101. })
  102. app.use(async (ctx, next) => {
  103. applySecurityHeaders(ctx, Boolean(config.tlsCert), config.allowedHosts)
  104. if (!isAllowedHostHeader(ctx.get('host'), config)) {
  105. throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
  106. }
  107. await next()
  108. })
  109. app.use(async (ctx, next) => {
  110. if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
  111. const secure = ctx.secure || Boolean(config.tlsCert)
  112. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
  113. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  114. }
  115. if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  116. throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
  117. }
  118. }
  119. await next()
  120. })
  121. app.use(bodyParser({
  122. encoding: 'utf-8',
  123. enableTypes: ['json'],
  124. jsonLimit: '2mb',
  125. onError: (_error, ctx) => {
  126. throw new HttpError(ctx.status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
  127. },
  128. }))
  129. const router = createApiRouter({ config, csrf, upstream, leases, uploads })
  130. app.use(router.routes())
  131. app.use(router.allowedMethods({ throw: false }))
  132. app.use(async (ctx, next) => {
  133. if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
  134. ctx.status = 404
  135. ctx.type = 'application/json; charset=utf-8'
  136. ctx.body = { error: 'Route not found', code: 'not_found' }
  137. return
  138. }
  139. await next()
  140. })
  141. return {
  142. app,
  143. config,
  144. csrf,
  145. leases,
  146. upstream,
  147. uploads,
  148. close: () => uploads.close(),
  149. }
  150. }
  151. export interface NodeServerRuntime {
  152. server: HttpServer
  153. close(): Promise<void>
  154. }
  155. export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
  156. const { config } = runtime
  157. const server: HttpServer = config.tlsCert && config.tlsKey
  158. ? createHttpsServer({
  159. cert: readFileSync(config.tlsCert),
  160. key: readFileSync(config.tlsKey),
  161. }, runtime.app.callback())
  162. : createHttpServer(runtime.app.callback())
  163. const removeWebSockets = installWebSocketRelay(server, config, runtime.leases)
  164. return {
  165. server,
  166. close: async () => {
  167. removeWebSockets()
  168. await new Promise<void>((resolve, reject) => {
  169. if (!server.listening) {
  170. resolve()
  171. return
  172. }
  173. server.close((error) => error ? reject(error) : resolve())
  174. })
  175. runtime.close()
  176. },
  177. }
  178. }