routes.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  1. import type Koa from 'koa'
  2. import Router from '@koa/router'
  3. import { parse } from 'cookie'
  4. import { createReadStream, realpathSync, statSync } from 'node:fs'
  5. import { basename, resolve, sep } from 'node:path'
  6. import { lookup as mimeLookup } from 'mime-types'
  7. import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '../shared/types.js'
  8. import type { ServerConfig } from './config.js'
  9. import { isLoopbackUpstream } from './config.js'
  10. import { HttpError } from './errors.js'
  11. import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
  12. import { acceptedRequestOrigin, accountKeyFromCookieHeader, CsrfProtection, requestAccountKey } from './security.js'
  13. import {
  14. applyUpstreamCookies,
  15. CookieJar,
  16. decodeUpstreamError,
  17. sendUpstreamResponse,
  18. type UpstreamResponse,
  19. UpstreamClient,
  20. } from './upstream.js'
  21. import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
  22. type JsonObject = Record<string, unknown>
  23. export interface RouteDependencies {
  24. config: ServerConfig
  25. csrf: CsrfProtection
  26. upstream: UpstreamClient
  27. leases: RealtimeLeaseStore
  28. uploads: UploadStore
  29. }
  30. function body(ctx: Koa.Context): JsonObject {
  31. const value = (ctx.request as Koa.Request & { body?: unknown }).body
  32. if (!value || typeof value !== 'object' || Array.isArray(value)) {
  33. throw new HttpError(400, 'A JSON object body is required', 'invalid_json_body')
  34. }
  35. return value as JsonObject
  36. }
  37. function parseJson(response: UpstreamResponse): JsonObject {
  38. try {
  39. const value = JSON.parse(response.body.toString('utf8')) as unknown
  40. if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
  41. return value as JsonObject
  42. } catch {
  43. throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
  44. }
  45. }
  46. function requireSuccess(response: UpstreamResponse): JsonObject {
  47. if (response.status < 200 || response.status >= 300) {
  48. const decoded = decodeUpstreamError(response)
  49. throw new HttpError(response.status, decoded.error, decoded.code)
  50. }
  51. return parseJson(response)
  52. }
  53. function searchFrom(ctx: Koa.Context, allowed: readonly string[]): URLSearchParams {
  54. const incoming = new URLSearchParams(ctx.querystring)
  55. const outgoing = new URLSearchParams()
  56. for (const name of allowed) {
  57. for (const value of incoming.getAll(name)) outgoing.append(name, value)
  58. }
  59. return outgoing
  60. }
  61. function safeIdentifier(value: string, label = 'identifier'): string {
  62. const normalized = value.trim()
  63. if (!normalized || normalized === '.' || normalized === '..'
  64. || normalized.length > 256 || /[\u0000-\u001f\u007f/\\]/.test(normalized)) {
  65. throw new HttpError(400, `Invalid ${label}`, 'invalid_identifier')
  66. }
  67. return normalized
  68. }
  69. function canonicalUUID(value: string, label: string): string {
  70. const normalized = value.toLowerCase()
  71. if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
  72. throw new HttpError(400, `${label} must be a UUID`, 'invalid_identifier')
  73. }
  74. return normalized
  75. }
  76. function normalizedProfiles(value: JsonObject): unknown[] {
  77. const profiles = Array.isArray(value.profiles) ? value.profiles : []
  78. return profiles.map((entry) => {
  79. if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
  80. const object = entry as JsonObject
  81. return object.profile && typeof object.profile === 'object' ? object.profile : object
  82. }
  83. return entry
  84. })
  85. }
  86. function profilesWithAgentNames(profiles: unknown[], pluginProfiles: JsonObject | undefined): unknown[] {
  87. const items = Array.isArray(pluginProfiles?.profiles) ? pluginProfiles.profiles : []
  88. const names = new Map<string, string>()
  89. for (const entry of items) {
  90. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
  91. const profile = entry as JsonObject
  92. const name = typeof profile.name === 'string' ? profile.name : ''
  93. const agentName = typeof profile.agentName === 'string' ? profile.agentName.trim() : ''
  94. if (name && agentName) names.set(name, agentName)
  95. }
  96. return profiles.map(entry => {
  97. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
  98. const profile = entry as JsonObject
  99. const name = typeof profile.name === 'string' ? profile.name : typeof profile.profile === 'string' ? profile.profile : ''
  100. const agentName = names.get(name)
  101. return agentName ? { ...profile, agentName } : profile
  102. })
  103. }
  104. function authRequired(status: JsonObject): boolean {
  105. return status.auth_required === true || status.authRequired === true
  106. }
  107. function publicStatus(status: JsonObject): JsonObject {
  108. return {
  109. state: typeof status.overall === 'string' ? status.overall : 'unknown',
  110. version: typeof status.version === 'string' ? status.version : undefined,
  111. gatewayRunning: status.gateway_running === true || status.gatewayRunning === true,
  112. gatewayState: typeof status.gateway_state === 'string'
  113. ? status.gateway_state
  114. : typeof status.gatewayState === 'string' ? status.gatewayState : undefined,
  115. }
  116. }
  117. async function withJar<T>(ctx: Koa.Context, run: (jar: CookieJar) => Promise<T>): Promise<T> {
  118. const jar = new CookieJar(ctx.get('cookie'))
  119. try {
  120. return await run(jar)
  121. } finally {
  122. applyUpstreamCookies(ctx, jar)
  123. }
  124. }
  125. function json(ctx: Koa.Context, status: number, value: unknown): void {
  126. ctx.status = status
  127. ctx.type = 'application/json; charset=utf-8'
  128. ctx.body = value
  129. }
  130. async function proxy(
  131. ctx: Koa.Context,
  132. upstream: UpstreamClient,
  133. path: string,
  134. options: {
  135. search?: URLSearchParams
  136. method?: string
  137. requestBody?: unknown
  138. requestHeaders?: Record<string, string>
  139. maxResponseBytes?: number
  140. } = {},
  141. ): Promise<void> {
  142. await withJar(ctx, async (jar) => {
  143. const response = await upstream.request(path, jar, {
  144. method: options.method,
  145. search: options.search,
  146. body: options.requestBody,
  147. headers: options.requestHeaders,
  148. maxResponseBytes: options.maxResponseBytes,
  149. })
  150. sendUpstreamResponse(ctx, response, jar)
  151. })
  152. }
  153. async function proxyOptionalUnread(
  154. ctx: Koa.Context,
  155. upstream: UpstreamClient,
  156. path: string,
  157. options: {
  158. search?: URLSearchParams
  159. method?: string
  160. requestBody?: unknown
  161. },
  162. fallback: unknown,
  163. ): Promise<void> {
  164. await withJar(ctx, async (jar) => {
  165. const response = await upstream.request(path, jar, {
  166. method: options.method,
  167. search: options.search,
  168. body: options.requestBody,
  169. })
  170. if (response.status === 404 || response.status === 405) {
  171. json(ctx, 200, fallback)
  172. return
  173. }
  174. sendUpstreamResponse(ctx, response, jar)
  175. })
  176. }
  177. async function bootstrap(
  178. ctx: Koa.Context,
  179. dependencies: RouteDependencies,
  180. jar: CookieJar,
  181. rotateCsrf = false,
  182. ): Promise<void> {
  183. const statusResponse = await dependencies.upstream.request('/api/status', jar)
  184. if (statusResponse.status < 200 || statusResponse.status >= 300) {
  185. sendUpstreamResponse(ctx, statusResponse, jar)
  186. return
  187. }
  188. const status = parseJson(statusResponse)
  189. const required = authRequired(status)
  190. const identityResponse = await dependencies.upstream.request('/api/auth/me', jar)
  191. if (required && [401, 403].includes(identityResponse.status)) {
  192. json(ctx, 200, {
  193. status: publicStatus(status),
  194. authRequired: required,
  195. authenticated: false,
  196. profiles: [],
  197. csrfToken: dependencies.csrf.issue(ctx, rotateCsrf),
  198. insecureLan: dependencies.config.insecureLan,
  199. groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
  200. })
  201. return
  202. }
  203. if (identityResponse.status < 200 || identityResponse.status >= 300) {
  204. // An auth-disabled local Gateway may not expose an identity route.
  205. if (required) {
  206. sendUpstreamResponse(ctx, identityResponse, jar)
  207. return
  208. }
  209. }
  210. const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
  211. if (profilesResponse.status < 200 || profilesResponse.status >= 300) {
  212. sendUpstreamResponse(ctx, profilesResponse, jar)
  213. return
  214. }
  215. const identity = identityResponse.status >= 200 && identityResponse.status < 300
  216. ? parseJson(identityResponse)
  217. : { user_id: 'local', display_name: '本机 Hermes', provider: 'local' }
  218. const pluginProfilesResponse = await dependencies.upstream.request('/api/plugins/yaoyao/profiles', jar)
  219. const profiles = profilesWithAgentNames(
  220. normalizedProfiles(parseJson(profilesResponse)),
  221. pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300 ? parseJson(pluginProfilesResponse) : undefined,
  222. )
  223. json(ctx, 200, {
  224. status: publicStatus(status),
  225. authRequired: required,
  226. authenticated: true,
  227. user: identity.user && typeof identity.user === 'object' ? identity.user : identity,
  228. profiles,
  229. csrfToken: dependencies.csrf.issue(ctx, rotateCsrf),
  230. insecureLan: dependencies.config.insecureLan,
  231. groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
  232. })
  233. }
  234. async function login(ctx: Koa.Context, dependencies: RouteDependencies, jar: CookieJar): Promise<void> {
  235. const request = body(ctx)
  236. const username = typeof request.username === 'string' ? request.username.trim() : ''
  237. const password = typeof request.password === 'string' ? request.password : ''
  238. if (!username || !password || username.length > 320 || password.length > 4_096) {
  239. throw new HttpError(400, 'Username and password are required', 'invalid_credentials')
  240. }
  241. const providersResponse = await dependencies.upstream.request('/api/auth/providers', jar)
  242. const providersBody = requireSuccess(providersResponse)
  243. const providers = Array.isArray(providersBody.providers) ? providersBody.providers : []
  244. const passwordProviders = providers.filter((entry): entry is JsonObject =>
  245. Boolean(entry && typeof entry === 'object' && !Array.isArray(entry)
  246. && (entry as JsonObject).supports_password === true),
  247. )
  248. const requestedProvider = typeof request.provider === 'string' ? request.provider.trim() : ''
  249. const provider = requestedProvider
  250. ? passwordProviders.find((entry) => entry.name === requestedProvider)
  251. : passwordProviders.find((entry) => String(entry.name).toLowerCase() === 'basic')
  252. ?? (passwordProviders.length === 1 ? passwordProviders[0] : undefined)
  253. if (!provider || typeof provider.name !== 'string') {
  254. throw new HttpError(403, 'Hermes does not offer the requested password provider', 'password_login_unavailable')
  255. }
  256. const response = await dependencies.upstream.request('/auth/password-login', jar, {
  257. method: 'POST',
  258. body: { provider: provider.name, username, password, next: '' },
  259. clientAddress: ctx.req.socket.remoteAddress,
  260. })
  261. const result = requireSuccess(response)
  262. if (result.ok !== true) throw new HttpError(401, 'Hermes rejected the login', 'login_failed')
  263. await bootstrap(ctx, dependencies, jar, true)
  264. }
  265. async function requireGatewayAuthentication(
  266. ctx: Koa.Context,
  267. dependencies: RouteDependencies,
  268. jar: CookieJar,
  269. ): Promise<void> {
  270. const status = requireSuccess(await dependencies.upstream.request('/api/status', jar))
  271. if (!authRequired(status)) return
  272. const identity = await dependencies.upstream.request('/api/auth/me', jar, {
  273. clientAddress: ctx.req.socket.remoteAddress,
  274. })
  275. if (identity.status === 401 || identity.status === 403) {
  276. throw new HttpError(401, 'Hermes authentication is required', 'authentication_required')
  277. }
  278. requireSuccess(identity)
  279. }
  280. async function issueLease(
  281. ctx: Koa.Context,
  282. dependencies: RouteDependencies,
  283. jar: CookieJar,
  284. ): Promise<void> {
  285. const request = body(ctx)
  286. const channel = request.channel
  287. if (channel !== 'chat' && channel !== 'groups') {
  288. throw new HttpError(400, 'channel must be chat or groups', 'invalid_channel')
  289. }
  290. const status = requireSuccess(await dependencies.upstream.request('/api/status', jar))
  291. if (channel === 'groups') {
  292. const capabilities = requireSuccess(await dependencies.upstream.request(
  293. '/api/plugins/yaoyao/v1/capabilities',
  294. jar,
  295. ))
  296. const protocolVersion = Number(capabilities.protocolVersion ?? capabilities.protocol_version)
  297. if (!isSupportedGroupProtocolVersion(protocolVersion)) {
  298. throw new HttpError(409, `Hermes group chat protocol ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL} is required`, 'unsupported_group_protocol')
  299. }
  300. }
  301. let credential: { name: 'ticket' | 'token'; value: string }
  302. if (authRequired(status)) {
  303. const ticketResponse = requireSuccess(await dependencies.upstream.request('/api/auth/ws-ticket', jar, {
  304. method: 'POST',
  305. }))
  306. if (typeof ticketResponse.ticket !== 'string' || !ticketResponse.ticket.trim()) {
  307. throw new HttpError(502, 'Hermes returned an empty WebSocket ticket', 'invalid_ticket')
  308. }
  309. credential = { name: 'ticket', value: ticketResponse.ticket.trim() }
  310. } else {
  311. const root = await dependencies.upstream.request('/', jar, { maxResponseBytes: 2 * 1_024 * 1_024 })
  312. const match = root.body.toString('utf8').match(/window\.__HERMES_SESSION_TOKEN__="([^"]+)"/)
  313. if (!match?.[1]) throw new HttpError(401, 'Hermes did not provide a local session token', 'missing_session_token')
  314. credential = { name: 'token', value: match[1] }
  315. }
  316. const origin = acceptedRequestOrigin(
  317. ctx.get('origin'),
  318. ctx.get('host'),
  319. ctx.secure || Boolean(dependencies.config.tlsCert),
  320. dependencies.config.allowedHosts,
  321. )
  322. if (!origin) throw new HttpError(400, 'Invalid request host', 'invalid_host')
  323. const accountKeys = new Set([
  324. requestAccountKey(ctx.req),
  325. accountKeyFromCookieHeader(jar.header, ctx.req.socket.remoteAddress),
  326. ])
  327. const lease = dependencies.leases.issue({
  328. channel: channel as RealtimeChannel,
  329. credential,
  330. origin,
  331. accountKeys,
  332. epoch: channel === 'groups' ? canonicalEpoch(request.epoch) : undefined,
  333. cursor: channel === 'groups' ? groupCursor(request.cursor) : undefined,
  334. })
  335. json(ctx, 201, { lease: lease.id, channel: lease.channel, expiresAt: lease.expiresAt })
  336. }
  337. async function searchSessions(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
  338. await withJar(ctx, async (jar) => {
  339. const query = searchFrom(ctx, ['q', 'limit', 'source', 'profile'])
  340. const text = query.get('q')?.trim()
  341. if (!text) throw new HttpError(400, 'q is required', 'missing_query')
  342. const limit = Math.max(1, Math.min(100, Number(query.get('limit') ?? '50') || 50))
  343. query.set('limit', String(limit))
  344. if (!query.get('source')) query.set('exclude_sources', 'cron,ios_group')
  345. if (query.get('profile')) {
  346. const response = await dependencies.upstream.request('/api/sessions/search', jar, { search: query })
  347. sendUpstreamResponse(ctx, response, jar)
  348. return
  349. }
  350. const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
  351. const profileObjects = normalizedProfiles(requireSuccess(profilesResponse))
  352. const profiles = profileObjects.flatMap((entry) => {
  353. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
  354. const name = (entry as JsonObject).name
  355. return typeof name === 'string' && name.trim() ? [name.trim()] : []
  356. })
  357. const results: JsonObject[] = []
  358. for (const profile of profiles) {
  359. const scoped = new URLSearchParams(query)
  360. scoped.set('profile', profile)
  361. const response = await dependencies.upstream.request('/api/sessions/search', jar, { search: scoped })
  362. const value = requireSuccess(response)
  363. for (const raw of Array.isArray(value.results) ? value.results : []) {
  364. if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
  365. results.push({ profile, ...(raw as JsonObject) })
  366. }
  367. }
  368. }
  369. results.sort((left, right) => {
  370. const leftRank = Number(left.rank ?? left.last_active ?? left.started_at ?? 0)
  371. const rightRank = Number(right.rank ?? right.last_active ?? right.started_at ?? 0)
  372. return rightRank - leftRank
  373. })
  374. json(ctx, 200, { results: results.slice(0, limit) })
  375. })
  376. }
  377. function groupPath(ctx: Koa.Context, suffix = ''): string {
  378. const roomID = canonicalUUID(ctx.params.roomID, 'room ID')
  379. return `/api/plugins/yaoyao/v1/rooms/${roomID}${suffix}`
  380. }
  381. function localMediaPath(root: string, relativePath: string): string {
  382. let resolvedRoot: string
  383. let resolvedFile: string
  384. try {
  385. resolvedRoot = realpathSync(root)
  386. resolvedFile = realpathSync(resolve(resolvedRoot, relativePath))
  387. } catch {
  388. throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
  389. }
  390. if (!resolvedFile.startsWith(`${resolvedRoot}${sep}`) || !statSync(resolvedFile).isFile()) {
  391. throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
  392. }
  393. return resolvedFile
  394. }
  395. function sendLocalMedia(ctx: Koa.Context, path: string): void {
  396. const size = statSync(path).size
  397. const type = mimeLookup(path) || 'application/octet-stream'
  398. const fileName = basename(path)
  399. const range = ctx.get('range').match(/^bytes=(\d*)-(\d*)$/)
  400. ctx.set('Accept-Ranges', 'bytes')
  401. ctx.set('Cache-Control', 'private, no-store')
  402. ctx.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`)
  403. ctx.type = type
  404. if (!range) {
  405. ctx.length = size
  406. ctx.body = createReadStream(path)
  407. return
  408. }
  409. const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0))
  410. const end = range[2] ? Number(range[2]) : size - 1
  411. if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
  412. ctx.status = 416
  413. ctx.set('Content-Range', `bytes */${size}`)
  414. return
  415. }
  416. const boundedEnd = Math.min(end, size - 1)
  417. ctx.status = 206
  418. ctx.set('Content-Range', `bytes ${start}-${boundedEnd}/${size}`)
  419. ctx.length = boundedEnd - start + 1
  420. ctx.body = createReadStream(path, { start, end: boundedEnd })
  421. }
  422. export function createApiRouter(dependencies: RouteDependencies): Router {
  423. const router = new Router()
  424. router.get('/healthz', (ctx) => {
  425. ctx.set('Cache-Control', 'no-store')
  426. json(ctx, 200, { ok: true })
  427. })
  428. // Historical messages can contain Markdown links such as
  429. // /Users/<owner>/Agents/<path>. Map only the configured media root, never
  430. // an arbitrary local filesystem path.
  431. router.get('/Users/:owner/Agents/*filePath', async (ctx) => {
  432. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  433. throw new HttpError(409, '本地媒体只支持回环 Hermes 上游', 'remote_local_media_disabled')
  434. }
  435. if (ctx.params.owner !== dependencies.config.mediaOwner) {
  436. throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
  437. }
  438. await withJar(ctx, async (jar) => {
  439. await requireGatewayAuthentication(ctx, dependencies, jar)
  440. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  441. sendLocalMedia(ctx, localMediaPath(dependencies.config.mediaRoot, filePath))
  442. })
  443. })
  444. router.get('/Users/:owner/.hermes/attachments/*filePath', async (ctx) => {
  445. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  446. throw new HttpError(409, '本地附件只支持回环 Hermes 上游', 'remote_local_media_disabled')
  447. }
  448. if (ctx.params.owner !== dependencies.config.mediaOwner) {
  449. throw new HttpError(404, '本地附件不存在', 'local_media_not_found')
  450. }
  451. await withJar(ctx, async (jar) => {
  452. await requireGatewayAuthentication(ctx, dependencies, jar)
  453. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  454. sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
  455. })
  456. })
  457. router.get('/Users/:owner/.hermes/images/*filePath', async (ctx) => {
  458. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  459. throw new HttpError(409, '本地图片只支持回环 Hermes 上游', 'remote_local_media_disabled')
  460. }
  461. if (ctx.params.owner !== dependencies.config.mediaOwner) {
  462. throw new HttpError(404, '本地图片不存在', 'local_media_not_found')
  463. }
  464. await withJar(ctx, async (jar) => {
  465. await requireGatewayAuthentication(ctx, dependencies, jar)
  466. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  467. sendLocalMedia(ctx, localMediaPath(dependencies.config.imagesRoot, filePath))
  468. })
  469. })
  470. router.get('/attachments/*filePath', async (ctx) => {
  471. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  472. throw new HttpError(409, '本地附件只支持回环 Hermes 上游', 'remote_local_media_disabled')
  473. }
  474. await withJar(ctx, async (jar) => {
  475. await requireGatewayAuthentication(ctx, dependencies, jar)
  476. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  477. sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
  478. })
  479. })
  480. router.get('/readyz', async (ctx) => {
  481. ctx.set('Cache-Control', 'no-store')
  482. try {
  483. const response = await dependencies.upstream.request(
  484. '/api/status',
  485. new CookieJar(''),
  486. { maxResponseBytes: 2 * 1_024 * 1_024 },
  487. )
  488. const reachable = response.status >= 200 && response.status < 500
  489. json(ctx, reachable ? 200 : 503, {
  490. ok: reachable,
  491. upstream: reachable ? 'reachable' : 'unavailable',
  492. })
  493. } catch {
  494. json(ctx, 503, { ok: false, upstream: 'unavailable' })
  495. }
  496. })
  497. router.get('/api/app/bootstrap', async (ctx) => {
  498. await withJar(ctx, (jar) => bootstrap(ctx, dependencies, jar))
  499. })
  500. router.post('/api/app/login', async (ctx) => {
  501. await withJar(ctx, (jar) => login(ctx, dependencies, jar))
  502. })
  503. router.post('/api/app/logout', async (ctx) => {
  504. await withJar(ctx, async (jar) => {
  505. const response = await dependencies.upstream.request('/auth/logout', jar, { method: 'POST' })
  506. const nextCsrf = dependencies.csrf.issue(ctx, true)
  507. if (response.status >= 400 && ![401, 403].includes(response.status)) {
  508. sendUpstreamResponse(ctx, response, jar)
  509. } else {
  510. json(ctx, 200, { ok: true, csrfToken: nextCsrf })
  511. }
  512. })
  513. })
  514. router.post('/api/app/realtime-leases', async (ctx) => {
  515. await withJar(ctx, (jar) => issueLease(ctx, dependencies, jar))
  516. })
  517. router.get('/api/app/profiles', async (ctx) => {
  518. await proxy(ctx, dependencies.upstream, '/api/profiles')
  519. })
  520. router.get('/api/app/models', async (ctx) => {
  521. const search = searchFrom(ctx, ['profile'])
  522. search.set('explicit_only', 'true')
  523. await proxy(ctx, dependencies.upstream, '/api/model/options', { search })
  524. })
  525. router.get('/api/app/sessions/search', async (ctx) => {
  526. await searchSessions(ctx, dependencies)
  527. })
  528. router.get('/api/app/sessions/unread', async (ctx) => {
  529. const search = searchFrom(ctx, ['profile'])
  530. await proxyOptionalUnread(
  531. ctx,
  532. dependencies.upstream,
  533. '/api/session-unread',
  534. { search },
  535. { profile: search.get('profile') || '', total_unread: 0, sessions: [], supported: false },
  536. )
  537. })
  538. router.patch('/api/app/sessions/unread/:sessionID', async (ctx) => {
  539. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  540. await proxyOptionalUnread(
  541. ctx,
  542. dependencies.upstream,
  543. `/api/session-unread/${encodeURIComponent(id)}`,
  544. {
  545. method: 'PATCH',
  546. search: searchFrom(ctx, ['profile']),
  547. requestBody: body(ctx),
  548. },
  549. { ok: true, supported: false },
  550. )
  551. })
  552. router.get('/api/app/sessions', async (ctx) => {
  553. const search = searchFrom(ctx, ['limit', 'offset', 'order', 'archived', 'profile', 'source'])
  554. search.set('exclude_sources', 'cron,ios_group')
  555. const path = search.get('profile') ? '/api/sessions' : '/api/profiles/sessions'
  556. await proxy(ctx, dependencies.upstream, path, { search })
  557. })
  558. router.get('/api/app/sessions/:sessionID/messages', async (ctx) => {
  559. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  560. const incoming = searchFrom(ctx, ['offset', 'limit', 'profile'])
  561. const offset = Math.max(0, Number(incoming.get('offset') ?? '0') || 0)
  562. const limit = Math.max(1, Math.min(500, Number(incoming.get('limit') ?? '100') || 100))
  563. incoming.set('offset', String(offset))
  564. incoming.set('limit', String(limit))
  565. incoming.set('order', 'latest')
  566. incoming.set('include_compacted', 'true')
  567. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}/messages`, {
  568. search: incoming,
  569. })
  570. })
  571. router.get('/api/app/sessions/:sessionID', async (ctx) => {
  572. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  573. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
  574. search: searchFrom(ctx, ['profile']),
  575. })
  576. })
  577. router.patch('/api/app/sessions/:sessionID', async (ctx) => {
  578. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  579. const search = searchFrom(ctx, ['profile'])
  580. const request = body(ctx)
  581. const profile = search.get('profile')
  582. if (profile) request.profile = profile
  583. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
  584. method: 'PATCH',
  585. search,
  586. requestBody: request,
  587. })
  588. })
  589. router.delete('/api/app/sessions/:sessionID', async (ctx) => {
  590. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  591. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
  592. method: 'DELETE',
  593. search: searchFrom(ctx, ['profile']),
  594. })
  595. })
  596. router.get('/api/app/groups/capabilities', async (ctx) => {
  597. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/capabilities')
  598. })
  599. router.get('/api/app/groups/rooms', async (ctx) => {
  600. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
  601. search: searchFrom(ctx, ['limit', 'cursor']),
  602. })
  603. })
  604. router.post('/api/app/groups/rooms', async (ctx) => {
  605. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
  606. method: 'POST', requestBody: body(ctx),
  607. })
  608. })
  609. router.get('/api/app/groups/rooms/:roomID', async (ctx) => {
  610. await proxy(ctx, dependencies.upstream, groupPath(ctx))
  611. })
  612. for (const method of ['patch', 'delete'] as const) {
  613. router[method]('/api/app/groups/rooms/:roomID', async (ctx) => {
  614. await proxy(ctx, dependencies.upstream, groupPath(ctx), {
  615. method: method.toUpperCase(), requestBody: body(ctx),
  616. })
  617. })
  618. }
  619. router.post('/api/app/groups/rooms/:roomID/agents', async (ctx) => {
  620. await proxy(ctx, dependencies.upstream, groupPath(ctx, '/agents'), {
  621. method: 'POST', requestBody: body(ctx),
  622. })
  623. })
  624. for (const method of ['patch', 'delete'] as const) {
  625. router[method]('/api/app/groups/rooms/:roomID/agents/:agentID', async (ctx) => {
  626. const agentID = canonicalUUID(ctx.params.agentID, 'agent ID')
  627. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/agents/${agentID}`), {
  628. method: method.toUpperCase(), requestBody: body(ctx),
  629. })
  630. })
  631. }
  632. router.post('/api/app/groups/rooms/:roomID/agents/:agentID/interrupt', async (ctx) => {
  633. const agentID = canonicalUUID(ctx.params.agentID, 'agent ID')
  634. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/agents/${agentID}/interrupt`), {
  635. method: 'POST', requestBody: body(ctx),
  636. })
  637. })
  638. router.get('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
  639. await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
  640. search: searchFrom(ctx, ['beforeSeq', 'afterSeq', 'limit']),
  641. })
  642. })
  643. router.post('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
  644. const request = body(ctx)
  645. const uploadIds = Array.isArray(request.uploadIds)
  646. ? request.uploadIds.map((value) => String(value))
  647. : []
  648. const upstreamBody = { ...request }
  649. delete upstreamBody.uploadIds
  650. if (uploadIds.length) {
  651. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  652. throw new HttpError(409, 'Group attachments require a loopback Hermes upstream', 'remote_upload_disabled')
  653. }
  654. const accountKey = requestAccountKey(ctx.req)
  655. const records = dependencies.uploads.records(uploadIds, accountKey)
  656. const content = typeof upstreamBody.content === 'string' ? upstreamBody.content.trim() : ''
  657. upstreamBody.content = [content, uploadMarkdown(records)].filter(Boolean).join('\n\n')
  658. await withJar(ctx, async (jar) => {
  659. const response = await dependencies.upstream.request(groupPath(ctx, '/messages'), jar, {
  660. method: 'POST', body: upstreamBody,
  661. })
  662. sendUpstreamResponse(ctx, response, jar)
  663. if (response.status >= 200 && response.status < 300) {
  664. dependencies.uploads.markReferenced(uploadIds, accountKey)
  665. }
  666. })
  667. return
  668. }
  669. await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
  670. method: 'POST', requestBody: upstreamBody,
  671. })
  672. })
  673. for (const action of ['approval', 'clarification'] as const) {
  674. router.post(`/api/app/groups/rooms/:roomID/interactions/:interactionID/${action}`, async (ctx) => {
  675. const interactionID = safeIdentifier(ctx.params.interactionID, 'interaction ID')
  676. await proxy(
  677. ctx,
  678. dependencies.upstream,
  679. groupPath(ctx, `/interactions/${encodeURIComponent(interactionID)}/${action}`),
  680. { method: 'POST', requestBody: body(ctx) },
  681. )
  682. })
  683. }
  684. router.post('/api/app/group-uploads', async (ctx) => {
  685. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  686. throw new HttpError(409, 'Group attachments require a loopback Hermes upstream', 'remote_upload_disabled')
  687. }
  688. await withJar(ctx, async (jar) => {
  689. await requireGatewayAuthentication(ctx, dependencies, jar)
  690. const accountKey = accountKeyFromCookieHeader(jar.header, ctx.req.socket.remoteAddress)
  691. const files = await receiveGroupUploads(ctx.req, dependencies.uploads, accountKey)
  692. json(ctx, 201, { files })
  693. })
  694. })
  695. router.get('/api/app/files', async (ctx) => {
  696. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/files', {
  697. search: searchFrom(ctx, ['limit', 'cursor', 'search', 'profile', 'kind', 'sender', 'session_id']),
  698. })
  699. })
  700. for (const action of ['download', 'preview'] as const) {
  701. router.get(`/api/app/files/:fileID/${action}`, async (ctx) => {
  702. const id = safeIdentifier(ctx.params.fileID, 'file ID')
  703. await proxy(ctx, dependencies.upstream, `/api/plugins/yaoyao/${encodeURIComponent(id)}/download`, {
  704. search: searchFrom(ctx, ['profile']),
  705. requestHeaders: ctx.get('range') ? { range: ctx.get('range') } : undefined,
  706. maxResponseBytes: 256 * 1_024 * 1_024,
  707. })
  708. if (action === 'preview') {
  709. const type = ctx.response.get('content-type').split(';', 1)[0]?.trim().toLowerCase()
  710. const activeContent = type === 'text/html'
  711. || type === 'application/xhtml+xml'
  712. || type === 'image/svg+xml'
  713. || Boolean(type?.endsWith('/xml') || type?.endsWith('+xml'))
  714. if (activeContent) {
  715. ctx.type = 'application/octet-stream'
  716. ctx.set('Content-Disposition', `attachment; filename="file-${id}"`)
  717. } else {
  718. ctx.remove('Content-Disposition')
  719. }
  720. }
  721. })
  722. }
  723. return router
  724. }
  725. export function incomingCookieNames(ctx: Koa.Context): string[] {
  726. return Object.keys(parse(ctx.get('cookie')))
  727. }