routes.ts 90 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211
  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 { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackHost, isLoopbackUpstream, isPrivateHost } from './config.js'
  10. import { HttpError } from './errors.js'
  11. import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
  12. import { compareReleaseVersions } from './releases.js'
  13. import {
  14. bearerToken,
  15. DEFAULT_NODE_SCOPES,
  16. NODE_PAIRING_PROTOCOL_VERSION,
  17. type NodeScope,
  18. NodePairingStore,
  19. } from './pairing.js'
  20. import { acceptedRequestOrigin, accountKeyFromCookieHeader, CsrfProtection, requestAccountKey } from './security.js'
  21. import {
  22. applyUpstreamCookies,
  23. CookieJar,
  24. decodeUpstreamError,
  25. sendUpstreamResponse,
  26. type UpstreamResponse,
  27. UpstreamClient,
  28. } from './upstream.js'
  29. import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
  30. import { SystemUpdateManager, type SystemUpdateStatus } from './updateManager.js'
  31. import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localAuth.js'
  32. import { AccountLoginPairingStore } from './accountPairing.js'
  33. import { UpstreamProfileIdentityService } from './profileIdentities.js'
  34. import { PushCoordinator } from './pushCoordinator.js'
  35. type JsonObject = Record<string, unknown>
  36. export interface RouteDependencies {
  37. config: ServerConfig
  38. csrf: CsrfProtection
  39. upstream: UpstreamClient
  40. leases: RealtimeLeaseStore
  41. pairings: NodePairingStore
  42. uploads: UploadStore
  43. updates: SystemUpdateManager
  44. auth: LocalAuthStore
  45. upstreamSession: UpstreamServiceSession
  46. accountPairings: AccountLoginPairingStore
  47. profileIdentities: UpstreamProfileIdentityService
  48. push: PushCoordinator
  49. }
  50. function body(ctx: Koa.Context): JsonObject {
  51. const value = (ctx.request as Koa.Request & { body?: unknown }).body
  52. if (!value || typeof value !== 'object' || Array.isArray(value)) {
  53. throw new HttpError(400, 'A JSON object body is required', 'invalid_json_body')
  54. }
  55. return value as JsonObject
  56. }
  57. function optionalBody(ctx: Koa.Context): JsonObject | undefined {
  58. const value = (ctx.request as Koa.Request & { body?: unknown }).body
  59. return value && typeof value === 'object' && !Array.isArray(value)
  60. ? value as JsonObject
  61. : undefined
  62. }
  63. async function boundedRawBody(ctx: Koa.Context, maximum: number): Promise<Buffer> {
  64. const chunks: Buffer[] = []
  65. let size = 0
  66. for await (const raw of ctx.req) {
  67. const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as Uint8Array)
  68. size += chunk.byteLength
  69. if (size > maximum) {
  70. throw new HttpError(413, 'Paired node attachment is too large', 'node_attachment_too_large')
  71. }
  72. chunks.push(chunk)
  73. }
  74. if (size === 0) throw new HttpError(400, 'Paired node attachment is empty', 'invalid_node_attachment')
  75. return Buffer.concat(chunks, size)
  76. }
  77. function parseJson(response: UpstreamResponse): JsonObject {
  78. try {
  79. const value = JSON.parse(response.body.toString('utf8')) as unknown
  80. if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
  81. return value as JsonObject
  82. } catch {
  83. throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
  84. }
  85. }
  86. function parseJsonValue(response: UpstreamResponse): unknown {
  87. try {
  88. return JSON.parse(response.body.toString('utf8')) as unknown
  89. } catch {
  90. throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
  91. }
  92. }
  93. function requireSuccess(response: UpstreamResponse): JsonObject {
  94. if (response.status < 200 || response.status >= 300) {
  95. const decoded = decodeUpstreamError(response)
  96. throw new HttpError(response.status, decoded.error, decoded.code)
  97. }
  98. return parseJson(response)
  99. }
  100. function searchFrom(ctx: Koa.Context, allowed: readonly string[]): URLSearchParams {
  101. const incoming = new URLSearchParams(ctx.querystring)
  102. const outgoing = new URLSearchParams()
  103. for (const name of allowed) {
  104. for (const value of incoming.getAll(name)) outgoing.append(name, value)
  105. }
  106. return outgoing
  107. }
  108. function safeIdentifier(value: string, label = 'identifier'): string {
  109. const normalized = value.trim()
  110. if (!normalized || normalized === '.' || normalized === '..'
  111. || normalized.length > 256 || /[\u0000-\u001f\u007f/\\]/.test(normalized)) {
  112. throw new HttpError(400, `Invalid ${label}`, 'invalid_identifier')
  113. }
  114. return normalized
  115. }
  116. function canonicalUUID(value: string, label: string): string {
  117. const normalized = value.toLowerCase()
  118. 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)) {
  119. throw new HttpError(400, `${label} must be a UUID`, 'invalid_identifier')
  120. }
  121. return normalized
  122. }
  123. function normalizedProfiles(value: JsonObject): unknown[] {
  124. const profiles = Array.isArray(value.profiles) ? value.profiles : []
  125. return profiles.map((entry) => {
  126. if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
  127. const object = entry as JsonObject
  128. return object.profile && typeof object.profile === 'object' ? object.profile : object
  129. }
  130. return entry
  131. })
  132. }
  133. function profilesWithHermesBotNames(profiles: unknown[], pluginProfiles: JsonObject | undefined): unknown[] {
  134. const items = Array.isArray(pluginProfiles?.profiles) ? pluginProfiles.profiles : []
  135. const identities = new Map<string, string>()
  136. for (const entry of items) {
  137. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
  138. const profile = entry as JsonObject
  139. const name = typeof profile.name === 'string' ? profile.name : ''
  140. const botName = typeof profile.botName === 'string' ? profile.botName.trim() : ''
  141. if (name && botName) identities.set(name, botName)
  142. }
  143. return profiles.map(entry => {
  144. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
  145. const profile = entry as JsonObject
  146. const name = typeof profile.name === 'string' ? profile.name : typeof profile.profile === 'string' ? profile.profile : ''
  147. const agentName = identities.get(name)
  148. return agentName ? {
  149. ...profile,
  150. agentName,
  151. description: agentName,
  152. display_name: agentName,
  153. } : profile
  154. })
  155. }
  156. function authRequired(status: JsonObject): boolean {
  157. return status.auth_required === true || status.authRequired === true
  158. }
  159. function publicStatus(status: JsonObject): JsonObject {
  160. return {
  161. state: typeof status.overall === 'string' ? status.overall : 'unknown',
  162. version: typeof status.version === 'string' ? status.version : undefined,
  163. gatewayRunning: status.gateway_running === true || status.gatewayRunning === true,
  164. gatewayState: typeof status.gateway_state === 'string'
  165. ? status.gateway_state
  166. : typeof status.gatewayState === 'string' ? status.gatewayState : undefined,
  167. }
  168. }
  169. async function yaoyaoPluginVersion(
  170. dependencies: RouteDependencies,
  171. jar: CookieJar,
  172. ): Promise<string | undefined> {
  173. const response = await dependencies.upstream.request('/api/dashboard/plugins', jar)
  174. if (response.status < 200 || response.status >= 300) return undefined
  175. const manifests = parseJsonValue(response)
  176. if (!Array.isArray(manifests)) return undefined
  177. const manifest = manifests.find(entry => (
  178. entry && typeof entry === 'object' && !Array.isArray(entry)
  179. && (entry as JsonObject).name === 'yaoyao'
  180. )) as JsonObject | undefined
  181. return typeof manifest?.version === 'string' ? manifest.version : undefined
  182. }
  183. async function requireYaoyaoStorageReady(
  184. dependencies: RouteDependencies,
  185. jar: CookieJar,
  186. installedVersion?: string,
  187. ): Promise<void> {
  188. if (!installedVersion) return
  189. const response = await dependencies.upstream.request(
  190. '/api/plugins/yaoyao/maintenance/storage',
  191. jar,
  192. )
  193. if (response.status < 200 || response.status >= 300) {
  194. throw new HttpError(
  195. 409,
  196. '当前夭夭插件尚未具备安全升级能力,请先按文档完成一次兼容版本安装',
  197. 'yaoyao_storage_migration_required',
  198. )
  199. }
  200. const storage = parseJson(response)
  201. if (storage.ready !== true) {
  202. throw new HttpError(
  203. 409,
  204. '检测到旧数据目录冲突,已停止升级以避免覆盖夭夭数据',
  205. 'yaoyao_storage_conflict',
  206. )
  207. }
  208. }
  209. let yaoyaoReconcileInFlight: Promise<JsonObject> | undefined
  210. function pluginVersionAtLeast(installed: string | undefined, expected: string): boolean {
  211. if (!installed) return false
  212. try {
  213. return compareReleaseVersions(installed, expected) >= 0
  214. } catch {
  215. return installed === expected
  216. }
  217. }
  218. async function reconcileYaoyaoPlugin(
  219. dependencies: RouteDependencies,
  220. jar: CookieJar,
  221. clientAddress: string | undefined,
  222. expectedVersion: string,
  223. ): Promise<JsonObject> {
  224. if (yaoyaoReconcileInFlight) return yaoyaoReconcileInFlight
  225. const task = (async () => {
  226. const installedVersion = await yaoyaoPluginVersion(dependencies, jar)
  227. if (pluginVersionAtLeast(installedVersion, expectedVersion)) {
  228. return {
  229. ok: true,
  230. updated: false,
  231. installedPluginVersion: installedVersion,
  232. expectedPluginVersion: expectedVersion,
  233. }
  234. }
  235. await requireYaoyaoStorageReady(dependencies, jar, installedVersion)
  236. const pluginSource = dependencies.config.yaoyaoPluginSource
  237. ?? DEFAULT_YAOYAO_PLUGIN_SOURCE
  238. const installedResult = requireSuccess(await dependencies.upstream.request(
  239. '/api/dashboard/agent-plugins/install',
  240. jar,
  241. {
  242. method: 'POST',
  243. body: {
  244. identifier: pluginSource,
  245. force: Boolean(installedVersion),
  246. enable: true,
  247. },
  248. clientAddress,
  249. },
  250. ))
  251. let actualVersion: string | undefined
  252. for (let attempt = 0; attempt < 20; attempt += 1) {
  253. actualVersion = await yaoyaoPluginVersion(dependencies, jar)
  254. if (pluginVersionAtLeast(actualVersion, expectedVersion)) break
  255. await new Promise(resolvePromise => setTimeout(resolvePromise, 250))
  256. }
  257. if (!pluginVersionAtLeast(actualVersion, expectedVersion)) {
  258. throw new HttpError(
  259. 502,
  260. `9119 插件更新后版本仍不匹配:期望 ${expectedVersion},实际 ${actualVersion || '未安装'}`,
  261. 'yaoyao_plugin_version_mismatch',
  262. )
  263. }
  264. return {
  265. ...installedResult,
  266. source: pluginSource,
  267. updated: true,
  268. installedPluginVersion: actualVersion,
  269. expectedPluginVersion: expectedVersion,
  270. restarted: false,
  271. restartRequired: false,
  272. }
  273. })()
  274. yaoyaoReconcileInFlight = task
  275. try {
  276. return await task
  277. } finally {
  278. if (yaoyaoReconcileInFlight === task) yaoyaoReconcileInFlight = undefined
  279. }
  280. }
  281. export function systemUpdateRequestAllowed(address: string, allowRemoteUpdate = false): boolean {
  282. if (allowRemoteUpdate) return true
  283. return isLoopbackHost(address.replace(/^::ffff:/, ''))
  284. }
  285. function localSystemUpdateAllowed(ctx: Koa.Context, dependencies: RouteDependencies): boolean {
  286. const address = (ctx.req.socket.remoteAddress ?? '').replace(/^::ffff:/, '')
  287. return systemUpdateRequestAllowed(address, dependencies.config.allowRemoteUpdate)
  288. }
  289. function systemUpdateStatusForRequest(
  290. ctx: Koa.Context,
  291. dependencies: RouteDependencies,
  292. status: SystemUpdateStatus,
  293. ): SystemUpdateStatus {
  294. if (!status.supported || localSystemUpdateAllowed(ctx, dependencies)) return status
  295. return {
  296. ...status,
  297. supported: false,
  298. unsupportedReason: '系统升级默认只允许在本机执行;可通过服务配置显式允许远程升级',
  299. }
  300. }
  301. function requireLocalSystemUpdate(ctx: Koa.Context, dependencies: RouteDependencies): void {
  302. if (!localSystemUpdateAllowed(ctx, dependencies)) {
  303. throw new HttpError(
  304. 403,
  305. '系统升级默认只允许在本机执行',
  306. 'remote_system_update_disabled',
  307. )
  308. }
  309. }
  310. function updateFailure(error: unknown): HttpError {
  311. const message = error instanceof Error ? error.message : String(error)
  312. if (/最新版本|目标版本|正在执行|可回滚|不支持/.test(message)) {
  313. return new HttpError(409, message, 'system_update_conflict')
  314. }
  315. return new HttpError(502, `无法访问系统发布源:${message}`, 'system_update_source_failed')
  316. }
  317. async function withJar<T>(ctx: Koa.Context, run: (jar: CookieJar) => Promise<T>): Promise<T> {
  318. const service = ctx.state.upstreamSession as UpstreamServiceSession | undefined
  319. if (service) {
  320. await service.ensure()
  321. return run(service.jar)
  322. }
  323. const jar = new CookieJar(ctx.get('cookie'))
  324. try { return await run(jar) } finally { applyUpstreamCookies(ctx, jar) }
  325. }
  326. function json(ctx: Koa.Context, status: number, value: unknown): void {
  327. ctx.status = status
  328. ctx.type = 'application/json; charset=utf-8'
  329. ctx.body = value
  330. }
  331. function pushRequest<T>(operation: () => T): T {
  332. try {
  333. return operation()
  334. } catch (cause) {
  335. const message = cause instanceof Error ? cause.message : '推送请求无效'
  336. throw new HttpError(400, message, 'invalid_push_request')
  337. }
  338. }
  339. function bestEffortAutoSubscribe(
  340. ctx: Koa.Context,
  341. dependencies: RouteDependencies,
  342. userID: string,
  343. roomID: string,
  344. baselineMessageSeq?: number,
  345. ): void {
  346. try {
  347. dependencies.push.setGroupSubscription(userID, roomID, true, baselineMessageSeq)
  348. } catch (cause) {
  349. const detail = cause instanceof Error ? cause.message : String(cause)
  350. ctx.app.emit('error', new Error(`团队消息已发送,但自动订阅推送失败:${detail}`), ctx)
  351. }
  352. }
  353. function bestEffortRemoveUserPush(
  354. ctx: Koa.Context,
  355. dependencies: RouteDependencies,
  356. userID: string,
  357. ): void {
  358. try {
  359. dependencies.push.removeUser(userID)
  360. } catch (cause) {
  361. const detail = cause instanceof Error ? cause.message : String(cause)
  362. ctx.app.emit('error', new Error(`用户权限已更新,但推送状态清理失败:${detail}`), ctx)
  363. }
  364. }
  365. function groupMessageSequence(response: UpstreamResponse): number | undefined {
  366. try {
  367. const payload = parseJson(response)
  368. const data = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
  369. ? payload.data as JsonObject : {}
  370. const message = payload.message && typeof payload.message === 'object' && !Array.isArray(payload.message)
  371. ? payload.message as JsonObject
  372. : data.message && typeof data.message === 'object' && !Array.isArray(data.message)
  373. ? data.message as JsonObject : {}
  374. const value = Number(message.seq ?? message.sequence)
  375. return Number.isSafeInteger(value) && value >= 0 ? value : undefined
  376. } catch {
  377. return undefined
  378. }
  379. }
  380. async function latestGroupMessageSequence(
  381. dependencies: RouteDependencies,
  382. roomID: string,
  383. ): Promise<number> {
  384. const response = await dependencies.upstreamSession.request(
  385. `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}/messages`,
  386. { search: new URLSearchParams({ limit: '1' }) },
  387. )
  388. if (response.status === 404) throw new HttpError(404, '团队不存在', 'group_not_found')
  389. const payload = requireSuccess(response)
  390. const data = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
  391. ? payload.data as JsonObject : {}
  392. const rows = Array.isArray(payload.items) ? payload.items
  393. : Array.isArray(payload.messages) ? payload.messages
  394. : Array.isArray(data.items) ? data.items
  395. : Array.isArray(data.messages) ? data.messages : []
  396. const latest = rows.reduce((maximum, raw) => {
  397. if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return maximum
  398. const value = Number((raw as JsonObject).seq ?? (raw as JsonObject).sequence)
  399. return Number.isSafeInteger(value) && value >= 0 ? Math.max(maximum, value) : maximum
  400. }, 0)
  401. return latest
  402. }
  403. function requestOrigin(ctx: Koa.Context, config: ServerConfig): string {
  404. const secure = ctx.secure || Boolean(config.tlsCert)
  405. return `${secure ? 'https' : 'http'}://${ctx.host}`
  406. }
  407. function pairingScopes(value: unknown): string[] | undefined {
  408. if (value === undefined) return undefined
  409. if (!Array.isArray(value) || !value.every((scope) => typeof scope === 'string')) {
  410. throw new HttpError(400, 'scopes must be a string array', 'invalid_scope')
  411. }
  412. return value
  413. }
  414. async function addPairedChildNode(
  415. qrPayload: string,
  416. nameValue: string | undefined,
  417. dependencies: RouteDependencies,
  418. ): Promise<JsonObject> {
  419. let deepLink: URL
  420. try { deepLink = new URL(qrPayload.trim()) } catch {
  421. throw new HttpError(400, '配对码格式无效', 'invalid_pairing_code')
  422. }
  423. if (deepLink.protocol !== 'yaoyao:' || deepLink.hostname !== 'pair' || deepLink.searchParams.get('v') !== '1') {
  424. throw new HttpError(400, '这不是 8800 节点配对码', 'invalid_pairing_code')
  425. }
  426. const serviceValue = deepLink.searchParams.get('url') ?? ''
  427. let serviceURL: URL
  428. try { serviceURL = new URL(serviceValue) } catch {
  429. throw new HttpError(400, '配对码缺少有效的 8800 地址', 'invalid_pairing_code')
  430. }
  431. if (!['http:', 'https:'].includes(serviceURL.protocol)
  432. || serviceURL.username || serviceURL.password || serviceURL.search || serviceURL.hash
  433. || (serviceURL.protocol === 'http:' && !isPrivateHost(serviceURL.hostname))) {
  434. throw new HttpError(400, '子节点必须使用可信局域网 HTTP 或 HTTPS', 'invalid_node_url')
  435. }
  436. const requestJSON = async (url: URL, init?: RequestInit): Promise<JsonObject> => {
  437. let response: Response
  438. let timeout: ReturnType<typeof setTimeout> | undefined
  439. try {
  440. response = await Promise.race([
  441. fetch(url, { ...init, redirect: 'error' }),
  442. new Promise<never>((_resolve, reject) => {
  443. timeout = setTimeout(() => reject(new Error('request timed out')), 20_000)
  444. timeout.unref()
  445. }),
  446. ])
  447. } catch (error) {
  448. const reason = error instanceof Error ? error.message : 'connection failed'
  449. throw new HttpError(502, `无法连接子 8800 节点:${reason}`, 'child_node_unavailable')
  450. } finally {
  451. if (timeout) clearTimeout(timeout)
  452. }
  453. let value: unknown
  454. try { value = await response.json() } catch { value = undefined }
  455. if (!response.ok || !value || typeof value !== 'object' || Array.isArray(value)) {
  456. throw new HttpError(response.status || 502, '子节点拒绝了配对请求', 'child_node_rejected')
  457. }
  458. return value as JsonObject
  459. }
  460. const capabilities = await requestJSON(new URL('/api/pair/v1/capabilities', serviceURL))
  461. if (capabilities.serviceType !== 'yaoyao-web') {
  462. throw new HttpError(409, '子节点必须是 8800 夭夭 Web 服务', 'child_node_must_be_8800')
  463. }
  464. const pairingId = deepLink.searchParams.get('id') ?? ''
  465. const secret = deepLink.searchParams.get('secret') ?? ''
  466. const expectedNode = deepLink.searchParams.get('node') ?? ''
  467. const expectedFingerprint = deepLink.searchParams.get('fingerprint') ?? ''
  468. const claimed = await requestJSON(new URL('/api/pair/v1/claim', serviceURL), {
  469. method: 'POST',
  470. headers: { accept: 'application/json', 'content-type': 'application/json' },
  471. body: JSON.stringify({ pairingId, secret, deviceName: '夭夭 8800 父节点' }),
  472. })
  473. if (claimed.serviceType !== 'yaoyao-web'
  474. || claimed.nodeId !== expectedNode
  475. || claimed.fingerprint !== expectedFingerprint
  476. || typeof claimed.serverUrl !== 'string'
  477. || typeof claimed.token !== 'string') {
  478. throw new HttpError(502, '子节点身份与二维码不一致', 'child_node_identity_mismatch')
  479. }
  480. const nodeURL = new URL(claimed.serverUrl)
  481. const profiles = await requestJSON(new URL(`${nodeURL.pathname.replace(/\/$/, '')}/api/profiles`, nodeURL), {
  482. headers: { accept: 'application/json', authorization: `Bearer ${claimed.token}` },
  483. })
  484. let profileIdentities = new Map<string, JsonObject>()
  485. try {
  486. const identityPayload = await requestJSON(
  487. new URL(`${nodeURL.pathname.replace(/\/$/, '')}/api/profile-identities`, nodeURL),
  488. { headers: { accept: 'application/json', authorization: `Bearer ${claimed.token}` } },
  489. )
  490. profileIdentities = new Map(
  491. (Array.isArray(identityPayload.profiles) ? identityPayload.profiles : [])
  492. .flatMap(value => {
  493. if (!value || typeof value !== 'object' || Array.isArray(value)) return []
  494. const identity = value as JsonObject
  495. return typeof identity.name === 'string' ? [[identity.name, identity] as const] : []
  496. }),
  497. )
  498. } catch {
  499. // Older 8800 nodes remain pairable; they simply use their REST names.
  500. }
  501. const profileItems = normalizedProfiles(profiles).flatMap(entry => {
  502. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
  503. const value = entry as JsonObject
  504. const name = typeof value.name === 'string' ? value.name : ''
  505. if (!name) return []
  506. const identity = profileIdentities.get(name)
  507. return [{
  508. name,
  509. displayName: typeof identity?.displayName === 'string' ? identity.displayName
  510. : typeof value.agentName === 'string' ? value.agentName
  511. : typeof value.display_name === 'string' ? value.display_name : name,
  512. model: typeof value.model === 'string' ? value.model : '',
  513. ...(typeof identity?.avatar === 'string' ? { avatar: identity.avatar } : {}),
  514. ...(typeof identity?.color === 'string' ? { color: identity.color } : {}),
  515. }]
  516. })
  517. const registration = {
  518. nodeId: claimed.nodeId,
  519. name: nameValue?.trim() || nodeURL.host,
  520. serverUrl: claimed.serverUrl,
  521. fingerprint: claimed.fingerprint,
  522. accessToken: claimed.token,
  523. profiles: profileItems,
  524. }
  525. const response = await dependencies.upstreamSession.request('/api/plugins/yaoyao/v1/nodes', {
  526. method: 'POST', body: registration,
  527. })
  528. return requireSuccess(response)
  529. }
  530. function pairedProxyScope(path: string, method: string): NodeScope {
  531. if (path === '/profiles' || path.startsWith('/profiles/')) {
  532. return path.includes('/sessions') ? 'history.read' : 'agents.read'
  533. }
  534. if (path === '/sessions' || path.startsWith('/sessions/')) {
  535. return ['GET', 'HEAD'].includes(method) ? 'history.read' : 'sessions.execute'
  536. }
  537. if (path.startsWith('/plugins/yaoyao/v1/')) {
  538. return ['GET', 'HEAD'].includes(method) ? 'groups.read' : 'groups.execute'
  539. }
  540. return 'sessions.execute'
  541. }
  542. function pairedProxyPath(rawPath: string): string {
  543. const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`
  544. if (path.includes('\\') || path.includes('\u0000') || path.length > 2_048) {
  545. throw new HttpError(400, 'Paired node path is invalid', 'invalid_node_path')
  546. }
  547. const allowed = path === '/status'
  548. || path === '/auth/me'
  549. || path === '/auth/ws-ticket'
  550. || path === '/profiles'
  551. || path.startsWith('/profiles/')
  552. || path === '/sessions'
  553. || path.startsWith('/sessions/')
  554. || path.startsWith('/model/')
  555. || path.startsWith('/models/')
  556. || path.startsWith('/files/')
  557. || path.startsWith('/attachments/')
  558. || path.startsWith('/plugins/yaoyao/')
  559. if (!allowed) throw new HttpError(404, 'Paired node route is not available', 'node_route_not_found')
  560. return `/api${path}`
  561. }
  562. async function proxy(
  563. ctx: Koa.Context,
  564. upstream: UpstreamClient,
  565. path: string,
  566. options: {
  567. search?: URLSearchParams
  568. method?: string
  569. requestBody?: unknown
  570. requestHeaders?: Record<string, string>
  571. maxResponseBytes?: number
  572. } = {},
  573. ): Promise<void> {
  574. await withJar(ctx, async (jar) => {
  575. const response = await upstream.request(path, jar, {
  576. method: options.method,
  577. search: options.search,
  578. body: options.requestBody,
  579. headers: options.requestHeaders,
  580. maxResponseBytes: options.maxResponseBytes,
  581. })
  582. sendUpstreamResponse(ctx, response, jar)
  583. })
  584. }
  585. async function proxyAdminFeature(
  586. ctx: Koa.Context,
  587. dependencies: RouteDependencies,
  588. path: string,
  589. options: {
  590. search?: URLSearchParams
  591. method?: string
  592. requestBody?: unknown
  593. } = {},
  594. ): Promise<void> {
  595. dependencies.auth.requireAdmin(ctx)
  596. await withJar(ctx, async (jar) => {
  597. const response = await dependencies.upstream.request(path, jar, {
  598. method: options.method,
  599. search: options.search,
  600. body: options.requestBody,
  601. })
  602. if (response.status === 404 || response.status === 405) {
  603. throw new HttpError(501, '当前上游版本不支持此管理功能', 'upstream_feature_unsupported')
  604. }
  605. sendUpstreamResponse(ctx, response, jar)
  606. })
  607. }
  608. async function proxyOptionalUnread(
  609. ctx: Koa.Context,
  610. upstream: UpstreamClient,
  611. path: string,
  612. options: {
  613. search?: URLSearchParams
  614. method?: string
  615. requestBody?: unknown
  616. },
  617. fallback: unknown,
  618. ): Promise<void> {
  619. await withJar(ctx, async (jar) => {
  620. const response = await upstream.request(path, jar, {
  621. method: options.method,
  622. search: options.search,
  623. body: options.requestBody,
  624. })
  625. if (response.status === 404 || response.status === 405) {
  626. json(ctx, 200, fallback)
  627. return
  628. }
  629. sendUpstreamResponse(ctx, response, jar)
  630. })
  631. }
  632. async function bootstrap(
  633. ctx: Koa.Context,
  634. dependencies: RouteDependencies,
  635. rotateCsrf = false,
  636. ): Promise<void> {
  637. const user = dependencies.auth.current(ctx)
  638. const csrfToken = dependencies.csrf.issue(ctx, rotateCsrf)
  639. if (!user) {
  640. json(ctx, 200, {
  641. status: { state: 'ready' },
  642. authRequired: true,
  643. authenticated: false,
  644. profiles: [],
  645. csrfToken,
  646. insecureLan: dependencies.config.insecureLan,
  647. groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
  648. upstreamReady: false,
  649. serverKind: 'yaoyao-web',
  650. })
  651. return
  652. }
  653. let status: JsonObject = { state: user.mustChangePassword ? 'password_change_required' : 'degraded' }
  654. let profiles: unknown[] = []
  655. let upstreamReady = false
  656. let upstreamError: string | undefined
  657. if (!user.mustChangePassword) {
  658. try {
  659. const statusResponse = await dependencies.upstreamSession.request('/api/status')
  660. const rawStatus = requireSuccess(statusResponse)
  661. status = publicStatus(rawStatus)
  662. const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
  663. const pluginProfilesResponse = await dependencies.upstreamSession.request('/api/plugins/yaoyao/profiles')
  664. profiles = profilesWithHermesBotNames(
  665. normalizedProfiles(requireSuccess(profilesResponse)),
  666. pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300
  667. ? parseJson(pluginProfilesResponse) : undefined,
  668. )
  669. upstreamReady = true
  670. } catch (error) {
  671. upstreamError = error instanceof Error ? error.message : '9119 不可用'
  672. }
  673. }
  674. json(ctx, 200, {
  675. status,
  676. authRequired: true,
  677. authenticated: true,
  678. user,
  679. profiles,
  680. csrfToken,
  681. insecureLan: dependencies.config.insecureLan,
  682. groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
  683. upstreamReady,
  684. upstreamError,
  685. serverKind: 'yaoyao-web',
  686. })
  687. }
  688. async function login(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
  689. const request = body(ctx)
  690. const username = typeof request.username === 'string' ? request.username.trim() : ''
  691. const password = typeof request.password === 'string' ? request.password : ''
  692. if (!username || !password || username.length > 320 || password.length > 4_096) {
  693. throw new HttpError(400, 'Username and password are required', 'invalid_credentials')
  694. }
  695. dependencies.auth.login(ctx, username, password)
  696. await bootstrap(ctx, dependencies, true)
  697. }
  698. async function independentPairingCookies(
  699. ctx: Koa.Context,
  700. dependencies: RouteDependencies,
  701. authenticatedJar: CookieJar,
  702. request: JsonObject | undefined,
  703. ): Promise<string> {
  704. const status = requireSuccess(
  705. await dependencies.upstream.request('/api/status', authenticatedJar),
  706. )
  707. if (!authRequired(status)) {
  708. const cookies = authenticatedJar.header
  709. if (!cookies) throw new HttpError(401, 'Hermes session is unavailable', 'authentication_required')
  710. return cookies
  711. }
  712. const username = typeof request?.username === 'string' ? request.username.trim() : ''
  713. const password = typeof request?.password === 'string' ? request.password : ''
  714. if (!username || !password || username.length > 320 || password.length > 4_096) {
  715. throw new HttpError(
  716. 400,
  717. 'Hermes username and password are required to create an independent paired-device session',
  718. 'pairing_credentials_required',
  719. )
  720. }
  721. const jar = new CookieJar('')
  722. const providersBody = requireSuccess(
  723. await dependencies.upstream.request('/api/auth/providers', jar),
  724. )
  725. const providers = Array.isArray(providersBody.providers) ? providersBody.providers : []
  726. const provider = providers.find((entry) => Boolean(
  727. entry && typeof entry === 'object' && !Array.isArray(entry)
  728. && (entry as JsonObject).supports_password === true
  729. && String((entry as JsonObject).name).toLowerCase() === 'basic',
  730. )) as JsonObject | undefined
  731. if (!provider || typeof provider.name !== 'string') {
  732. throw new HttpError(
  733. 403,
  734. 'Hermes does not offer independent password sessions for QR pairing',
  735. 'pairing_login_unavailable',
  736. )
  737. }
  738. const response = await dependencies.upstream.request('/auth/password-login', jar, {
  739. method: 'POST',
  740. body: { provider: provider.name, username, password, next: '' },
  741. clientAddress: ctx.req.socket.remoteAddress,
  742. })
  743. const result = requireSuccess(response)
  744. if (result.ok !== true) throw new HttpError(401, 'Hermes rejected the pairing login', 'pairing_login_failed')
  745. await requireGatewayAuthentication(ctx, dependencies, jar)
  746. if (!jar.header) throw new HttpError(502, 'Hermes did not issue paired-device cookies', 'pairing_session_missing')
  747. return jar.header
  748. }
  749. async function requireGatewayAuthentication(
  750. ctx: Koa.Context,
  751. dependencies: RouteDependencies,
  752. jar: CookieJar,
  753. ): Promise<void> {
  754. const status = requireSuccess(await dependencies.upstream.request('/api/status', jar))
  755. if (!authRequired(status)) return
  756. const identity = await dependencies.upstream.request('/api/auth/me', jar, {
  757. clientAddress: ctx.req.socket.remoteAddress,
  758. })
  759. if (identity.status === 401 || identity.status === 403) {
  760. throw new HttpError(401, 'Hermes authentication is required', 'authentication_required')
  761. }
  762. requireSuccess(identity)
  763. }
  764. async function issueLease(
  765. ctx: Koa.Context,
  766. dependencies: RouteDependencies,
  767. jar: CookieJar,
  768. ): Promise<void> {
  769. const request = body(ctx)
  770. const channel = request.channel
  771. if (channel !== 'chat' && channel !== 'groups') {
  772. throw new HttpError(400, 'channel must be chat or groups', 'invalid_channel')
  773. }
  774. const status = requireSuccess(await dependencies.upstream.request('/api/status', jar))
  775. if (channel === 'groups') {
  776. const capabilities = requireSuccess(await dependencies.upstream.request(
  777. '/api/plugins/yaoyao/v1/capabilities',
  778. jar,
  779. ))
  780. const protocolVersion = Number(capabilities.protocolVersion ?? capabilities.protocol_version)
  781. if (!isSupportedGroupProtocolVersion(protocolVersion)) {
  782. throw new HttpError(409, `Hermes group chat protocol ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL} is required`, 'unsupported_group_protocol')
  783. }
  784. }
  785. let credential: { name: 'ticket' | 'token'; value: string }
  786. if (authRequired(status)) {
  787. const ticketResponse = requireSuccess(await dependencies.upstream.request('/api/auth/ws-ticket', jar, {
  788. method: 'POST',
  789. }))
  790. if (typeof ticketResponse.ticket !== 'string' || !ticketResponse.ticket.trim()) {
  791. throw new HttpError(502, 'Hermes returned an empty WebSocket ticket', 'invalid_ticket')
  792. }
  793. credential = { name: 'ticket', value: ticketResponse.ticket.trim() }
  794. } else {
  795. const root = await dependencies.upstream.request('/', jar, { maxResponseBytes: 2 * 1_024 * 1_024 })
  796. const match = root.body.toString('utf8').match(/window\.__HERMES_SESSION_TOKEN__="([^"]+)"/)
  797. if (!match?.[1]) throw new HttpError(401, 'Hermes did not provide a local session token', 'missing_session_token')
  798. credential = { name: 'token', value: match[1] }
  799. }
  800. const origin = acceptedRequestOrigin(
  801. ctx.get('origin'),
  802. ctx.get('host'),
  803. ctx.secure || Boolean(dependencies.config.tlsCert),
  804. dependencies.config.allowedHosts,
  805. )
  806. if (!origin) throw new HttpError(400, 'Invalid request host', 'invalid_host')
  807. const accountKeys = new Set([
  808. requestAccountKey(ctx.req),
  809. accountKeyFromCookieHeader(jar.header, ctx.req.socket.remoteAddress),
  810. ])
  811. const lease = dependencies.leases.issue({
  812. channel: channel as RealtimeChannel,
  813. credential,
  814. origin,
  815. accountKeys,
  816. localUserID: (ctx.state.localUser as LocalUser | undefined)?.id,
  817. epoch: channel === 'groups' ? canonicalEpoch(request.epoch) : undefined,
  818. cursor: channel === 'groups' ? groupCursor(request.cursor) : undefined,
  819. })
  820. json(ctx, 201, { lease: lease.id, channel: lease.channel, expiresAt: lease.expiresAt })
  821. }
  822. async function searchSessions(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
  823. await withJar(ctx, async (jar) => {
  824. const query = searchFrom(ctx, ['q', 'limit', 'source', 'profile'])
  825. const text = query.get('q')?.trim()
  826. if (!text) throw new HttpError(400, 'q is required', 'missing_query')
  827. const limit = Math.max(1, Math.min(100, Number(query.get('limit') ?? '50') || 50))
  828. query.set('limit', String(limit))
  829. if (!query.get('source')) query.set('exclude_sources', 'cron,ios_group')
  830. if (query.get('profile')) {
  831. const response = await dependencies.upstream.request('/api/sessions/search', jar, { search: query })
  832. sendUpstreamResponse(ctx, response, jar)
  833. return
  834. }
  835. const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
  836. const profileObjects = normalizedProfiles(requireSuccess(profilesResponse))
  837. const profiles = profileObjects.flatMap((entry) => {
  838. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
  839. const name = (entry as JsonObject).name
  840. return typeof name === 'string' && name.trim() ? [name.trim()] : []
  841. })
  842. const results: JsonObject[] = []
  843. for (const profile of profiles) {
  844. const scoped = new URLSearchParams(query)
  845. scoped.set('profile', profile)
  846. const response = await dependencies.upstream.request('/api/sessions/search', jar, { search: scoped })
  847. const value = requireSuccess(response)
  848. for (const raw of Array.isArray(value.results) ? value.results : []) {
  849. if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
  850. results.push({ profile, ...(raw as JsonObject) })
  851. }
  852. }
  853. }
  854. results.sort((left, right) => {
  855. const leftRank = Number(left.rank ?? left.last_active ?? left.started_at ?? 0)
  856. const rightRank = Number(right.rank ?? right.last_active ?? right.started_at ?? 0)
  857. return rightRank - leftRank
  858. })
  859. json(ctx, 200, { results: results.slice(0, limit) })
  860. })
  861. }
  862. function groupPath(ctx: Koa.Context, suffix = ''): string {
  863. const roomID = canonicalUUID(ctx.params.roomID, 'room ID')
  864. return `/api/plugins/yaoyao/v1/rooms/${roomID}${suffix}`
  865. }
  866. function localMediaPath(root: string, relativePath: string): string {
  867. let resolvedRoot: string
  868. let resolvedFile: string
  869. try {
  870. resolvedRoot = realpathSync(root)
  871. resolvedFile = realpathSync(resolve(resolvedRoot, relativePath))
  872. } catch {
  873. throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
  874. }
  875. if (!resolvedFile.startsWith(`${resolvedRoot}${sep}`) || !statSync(resolvedFile).isFile()) {
  876. throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
  877. }
  878. return resolvedFile
  879. }
  880. function sendLocalMedia(ctx: Koa.Context, path: string): void {
  881. const size = statSync(path).size
  882. const type = mimeLookup(path) || 'application/octet-stream'
  883. const fileName = basename(path)
  884. const range = ctx.get('range').match(/^bytes=(\d*)-(\d*)$/)
  885. ctx.set('Accept-Ranges', 'bytes')
  886. ctx.set('Cache-Control', 'private, no-store')
  887. ctx.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`)
  888. ctx.type = type
  889. if (!range) {
  890. ctx.length = size
  891. ctx.body = createReadStream(path)
  892. return
  893. }
  894. const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0))
  895. const end = range[2] ? Number(range[2]) : size - 1
  896. if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
  897. ctx.status = 416
  898. ctx.set('Content-Range', `bytes */${size}`)
  899. return
  900. }
  901. const boundedEnd = Math.min(end, size - 1)
  902. ctx.status = 206
  903. ctx.set('Content-Range', `bytes ${start}-${boundedEnd}/${size}`)
  904. ctx.length = boundedEnd - start + 1
  905. ctx.body = createReadStream(path, { start, end: boundedEnd })
  906. }
  907. function pairedLocalMediaPath(config: ServerConfig, rawPath: string): string {
  908. if (!rawPath || rawPath.length > 8_192 || rawPath.includes('\u0000')) {
  909. throw new HttpError(400, '远程文件路径无效', 'invalid_node_file_path')
  910. }
  911. let file: string
  912. try { file = realpathSync(rawPath) } catch {
  913. throw new HttpError(404, '远程文件不存在', 'node_file_not_found')
  914. }
  915. const roots = [config.mediaRoot, config.attachmentsRoot, config.imagesRoot].flatMap(root => {
  916. try { return [realpathSync(root)] } catch { return [] }
  917. })
  918. if (!roots.some(root => file.startsWith(`${root}${sep}`)) || !statSync(file).isFile()) {
  919. throw new HttpError(403, '远程文件不在允许的目录中', 'node_file_forbidden')
  920. }
  921. return file
  922. }
  923. export function createApiRouter(dependencies: RouteDependencies): Router {
  924. const router = new Router()
  925. router.get('/healthz', (ctx) => {
  926. ctx.set('Cache-Control', 'no-store')
  927. json(ctx, 200, { ok: true })
  928. })
  929. // Hermes Gateway-compatible authentication surface for native clients.
  930. // Content requests are still executed through the server-owned 9119 session.
  931. router.get('/api/status', (ctx) => {
  932. json(ctx, 200, {
  933. overall: 'ready',
  934. auth_required: true,
  935. gateway_running: true,
  936. server_kind: 'yaoyao-web',
  937. node_transport: '8800',
  938. })
  939. })
  940. router.get('/api/auth/providers', (ctx) => {
  941. json(ctx, 200, { providers: [{ name: 'basic', supports_password: true }] })
  942. })
  943. router.post('/auth/password-login', async (ctx) => {
  944. const request = body(ctx)
  945. const username = typeof request.username === 'string' ? request.username : ''
  946. const password = typeof request.password === 'string' ? request.password : ''
  947. const user = dependencies.auth.login(ctx, username, password)
  948. json(ctx, 200, { ok: true, must_change_password: user.mustChangePassword, server_kind: 'yaoyao-web' })
  949. })
  950. router.post('/auth/logout', (ctx) => {
  951. dependencies.auth.logout(ctx)
  952. json(ctx, 200, { ok: true })
  953. })
  954. router.get('/api/auth/me', (ctx) => {
  955. const user = dependencies.auth.require(ctx, true)
  956. json(ctx, 200, {
  957. user_id: user.id,
  958. display_name: user.username,
  959. username: user.username,
  960. provider: 'yaoyao-local',
  961. role: user.role,
  962. must_change_password: user.mustChangePassword,
  963. server_kind: 'yaoyao-web',
  964. })
  965. })
  966. router.get('/api/profile-identities', async (ctx) => {
  967. dependencies.auth.require(ctx, true)
  968. json(ctx, 200, { profiles: await dependencies.profileIdentities.load() })
  969. })
  970. router.get('/api/profiles', async (ctx) => {
  971. dependencies.auth.require(ctx, true)
  972. const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
  973. const pluginProfilesResponse = await dependencies.upstreamSession.request('/api/plugins/yaoyao/profiles')
  974. json(ctx, profilesResponse.status, {
  975. profiles: profilesWithHermesBotNames(
  976. normalizedProfiles(requireSuccess(profilesResponse)),
  977. pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300
  978. ? parseJson(pluginProfilesResponse) : undefined,
  979. ),
  980. })
  981. })
  982. router.get('/api/push/v1/capabilities', (ctx) => {
  983. dependencies.auth.require(ctx)
  984. const capabilities = dependencies.push.capabilities()
  985. json(ctx, 200, {
  986. ...capabilities,
  987. maximumSummaryCharacters: capabilities.maxSummaryCharacters,
  988. })
  989. })
  990. router.put('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
  991. const user = dependencies.auth.require(ctx)
  992. const request = body(ctx)
  993. if (request.environment !== 'development' && request.environment !== 'production') {
  994. throw new HttpError(400, 'environment must be development or production', 'invalid_push_request')
  995. }
  996. const environment = request.environment
  997. const installation = pushRequest(() => dependencies.push.registerInstallation({
  998. userId: user.id,
  999. installationId: canonicalUUID(ctx.params.installationID, 'installation ID'),
  1000. clientAccountId: canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
  1001. deviceToken: typeof request.token === 'string'
  1002. ? request.token
  1003. : typeof request.deviceToken === 'string' ? request.deviceToken : '',
  1004. environment,
  1005. appVersion: typeof request.appVersion === 'string' ? request.appVersion : undefined,
  1006. authorizationVersion: dependencies.auth.pushAuthorizationVersion(user.id),
  1007. }))
  1008. json(ctx, 200, { installation })
  1009. })
  1010. router.delete('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
  1011. const user = dependencies.auth.require(ctx)
  1012. const removed = pushRequest(() => dependencies.push.unregisterInstallation(
  1013. user.id,
  1014. canonicalUUID(ctx.params.installationID, 'installation ID'),
  1015. canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
  1016. ))
  1017. json(ctx, 200, { ok: true, removed })
  1018. })
  1019. router.post('/api/push/v1/installations/:installationID/accounts/:clientAccountID/badge-reset', (ctx) => {
  1020. const user = dependencies.auth.require(ctx)
  1021. const badge = pushRequest(() => dependencies.push.resetBadge(
  1022. user.id,
  1023. canonicalUUID(ctx.params.installationID, 'installation ID'),
  1024. canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
  1025. ))
  1026. json(ctx, 200, { badge })
  1027. })
  1028. router.get('/api/push/v1/group-subscriptions', (ctx) => {
  1029. const user = dependencies.auth.require(ctx)
  1030. const subscriptions = dependencies.push.listGroupSubscriptions(user.id)
  1031. json(ctx, 200, {
  1032. subscriptions,
  1033. roomIds: subscriptions.filter(item => item.enabled).map(item => item.roomId),
  1034. })
  1035. })
  1036. router.put('/api/push/v1/group-subscriptions/:roomID', async (ctx) => {
  1037. const user = dependencies.auth.require(ctx)
  1038. const request = body(ctx)
  1039. if (typeof request.enabled !== 'boolean') {
  1040. throw new HttpError(400, 'enabled must be a boolean', 'invalid_push_request')
  1041. }
  1042. const enabled = request.enabled
  1043. const roomID = canonicalUUID(ctx.params.roomID, 'room ID')
  1044. const baseline = enabled ? await latestGroupMessageSequence(dependencies, roomID) : undefined
  1045. const subscription = pushRequest(() => dependencies.push.setGroupSubscription(
  1046. user.id,
  1047. roomID,
  1048. enabled,
  1049. baseline,
  1050. ))
  1051. json(ctx, 200, { subscription, enabled: subscription.enabled })
  1052. })
  1053. // Browser aliases stay inside the /api/app CSRF boundary. Native iOS uses
  1054. // the Gateway-compatible /api/push/v1 surface with the same account state.
  1055. router.get('/api/app/push/v1/capabilities', (ctx) => {
  1056. dependencies.auth.require(ctx)
  1057. const capabilities = dependencies.push.capabilities()
  1058. json(ctx, 200, {
  1059. ...capabilities,
  1060. maximumSummaryCharacters: capabilities.maxSummaryCharacters,
  1061. })
  1062. })
  1063. router.get('/api/app/push/v1/group-subscriptions', (ctx) => {
  1064. const user = dependencies.auth.require(ctx)
  1065. const subscriptions = dependencies.push.listGroupSubscriptions(user.id)
  1066. json(ctx, 200, {
  1067. subscriptions,
  1068. roomIds: subscriptions.filter(item => item.enabled).map(item => item.roomId),
  1069. })
  1070. })
  1071. router.put('/api/app/push/v1/group-subscriptions/:roomID', async (ctx) => {
  1072. const user = dependencies.auth.require(ctx)
  1073. const request = body(ctx)
  1074. if (typeof request.enabled !== 'boolean') {
  1075. throw new HttpError(400, 'enabled must be a boolean', 'invalid_push_request')
  1076. }
  1077. const enabled = request.enabled
  1078. const roomID = canonicalUUID(ctx.params.roomID, 'room ID')
  1079. const baseline = enabled ? await latestGroupMessageSequence(dependencies, roomID) : undefined
  1080. const subscription = pushRequest(() => dependencies.push.setGroupSubscription(
  1081. user.id,
  1082. roomID,
  1083. enabled,
  1084. baseline,
  1085. ))
  1086. json(ctx, 200, { subscription, enabled: subscription.enabled })
  1087. })
  1088. router.put('/api/account/credentials', (ctx) => {
  1089. const request = body(ctx)
  1090. const currentPassword = typeof request.currentPassword === 'string' ? request.currentPassword : ''
  1091. const newPassword = typeof request.newPassword === 'string' ? request.newPassword : ''
  1092. const username = typeof request.username === 'string' ? request.username : undefined
  1093. const user = dependencies.auth.changeCredentials(ctx, currentPassword, newPassword, username)
  1094. json(ctx, 200, { user, must_change_password: false, server_kind: 'yaoyao-web' })
  1095. })
  1096. router.post('/api/app/account-pairings', (ctx) => {
  1097. const user = dependencies.auth.require(ctx)
  1098. const pairing = dependencies.accountPairings.create(user.id)
  1099. const origin = requestOrigin(ctx, dependencies.config)
  1100. const deepLink = new URL('yaoyao://login')
  1101. deepLink.searchParams.set('v', '1')
  1102. deepLink.searchParams.set('url', origin)
  1103. deepLink.searchParams.set('id', pairing.id)
  1104. deepLink.searchParams.set('secret', pairing.secret)
  1105. json(ctx, 201, {
  1106. protocolVersion: 1,
  1107. serviceType: 'yaoyao-web',
  1108. pairingId: pairing.id,
  1109. expiresAt: pairing.expiresAt,
  1110. qrPayload: deepLink.toString(),
  1111. })
  1112. })
  1113. router.get('/api/app/account-pairings/:pairingID', (ctx) => {
  1114. dependencies.auth.require(ctx)
  1115. json(ctx, 200, dependencies.accountPairings.status(ctx.params.pairingID))
  1116. })
  1117. router.get('/api/account-pair/v1/capabilities', (ctx) => {
  1118. json(ctx, 200, {
  1119. protocolVersion: 1,
  1120. serviceType: 'yaoyao-web',
  1121. feature: 'server-login',
  1122. })
  1123. })
  1124. router.post('/api/account-pair/v1/claim', (ctx) => {
  1125. const request = body(ctx)
  1126. const pairingID = typeof request.pairingId === 'string' ? request.pairingId : ''
  1127. const secret = typeof request.secret === 'string' ? request.secret : ''
  1128. const userID = dependencies.accountPairings.claim(pairingID, secret)
  1129. const user = dependencies.auth.issueSession(ctx, userID)
  1130. json(ctx, 201, {
  1131. protocolVersion: 1,
  1132. serviceType: 'yaoyao-web',
  1133. serverUrl: requestOrigin(ctx, dependencies.config),
  1134. user,
  1135. })
  1136. })
  1137. router.post('/api/app/pairings', async (ctx) => {
  1138. dependencies.auth.requireAdmin(ctx)
  1139. await withJar(ctx, async (jar) => {
  1140. await requireGatewayAuthentication(ctx, dependencies, jar)
  1141. const request = optionalBody(ctx)
  1142. const cookieHeader = jar.header
  1143. if (!cookieHeader) throw new HttpError(503, '9119 服务会话尚未就绪', 'upstream_session_missing')
  1144. const pairing = dependencies.pairings.create(
  1145. cookieHeader,
  1146. pairingScopes(request?.scopes),
  1147. )
  1148. const origin = requestOrigin(ctx, dependencies.config)
  1149. const deepLink = new URL('yaoyao://pair')
  1150. deepLink.searchParams.set('v', String(NODE_PAIRING_PROTOCOL_VERSION))
  1151. deepLink.searchParams.set('url', origin)
  1152. deepLink.searchParams.set('node', pairing.nodeID)
  1153. deepLink.searchParams.set('id', pairing.id)
  1154. deepLink.searchParams.set('secret', pairing.secret)
  1155. deepLink.searchParams.set('fingerprint', pairing.fingerprint)
  1156. json(ctx, 201, {
  1157. protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
  1158. serviceType: 'yaoyao-web',
  1159. pairingId: pairing.id,
  1160. nodeId: pairing.nodeID,
  1161. fingerprint: pairing.fingerprint,
  1162. scopes: pairing.scopes,
  1163. expiresAt: pairing.expiresAt,
  1164. qrPayload: deepLink.toString(),
  1165. })
  1166. })
  1167. })
  1168. router.get('/api/app/pairings/:pairingID', async (ctx) => {
  1169. dependencies.auth.requireAdmin(ctx)
  1170. await withJar(ctx, async (jar) => {
  1171. await requireGatewayAuthentication(ctx, dependencies, jar)
  1172. json(ctx, 200, dependencies.pairings.status(ctx.params.pairingID))
  1173. })
  1174. })
  1175. router.get('/api/app/paired-devices', async (ctx) => {
  1176. dependencies.auth.requireAdmin(ctx)
  1177. await withJar(ctx, async (jar) => {
  1178. await requireGatewayAuthentication(ctx, dependencies, jar)
  1179. json(ctx, 200, {
  1180. nodeId: dependencies.pairings.nodeID,
  1181. fingerprint: dependencies.pairings.fingerprint,
  1182. devices: dependencies.pairings.list(),
  1183. })
  1184. })
  1185. })
  1186. router.delete('/api/app/paired-devices/:deviceID', async (ctx) => {
  1187. dependencies.auth.requireAdmin(ctx)
  1188. await withJar(ctx, async (jar) => {
  1189. await requireGatewayAuthentication(ctx, dependencies, jar)
  1190. try {
  1191. const delegated = new CookieJar(
  1192. dependencies.pairings.delegatedCookies(ctx.params.deviceID),
  1193. )
  1194. await dependencies.upstream.request('/auth/logout', delegated, {
  1195. method: 'POST', clientAddress: ctx.req.socket.remoteAddress,
  1196. })
  1197. } catch {
  1198. // Revocation of the local delegation remains authoritative even when
  1199. // Hermes is temporarily unreachable; its cookie is never returned.
  1200. }
  1201. if (!dependencies.pairings.revoke(ctx.params.deviceID)) {
  1202. throw new HttpError(404, 'Paired device was not found', 'paired_device_not_found')
  1203. }
  1204. json(ctx, 200, { ok: true })
  1205. })
  1206. })
  1207. router.post('/api/pair/v1/claim', (ctx) => {
  1208. const request = body(ctx)
  1209. const pairingID = typeof request.pairingId === 'string' ? request.pairingId : ''
  1210. const secret = typeof request.secret === 'string' ? request.secret : ''
  1211. const deviceName = typeof request.deviceName === 'string' ? request.deviceName : ''
  1212. const claimed = dependencies.pairings.claim({ pairingID, secret, deviceName })
  1213. const origin = requestOrigin(ctx, dependencies.config)
  1214. json(ctx, 201, {
  1215. protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
  1216. serviceType: 'yaoyao-web',
  1217. nodeId: claimed.nodeID,
  1218. fingerprint: claimed.fingerprint,
  1219. deviceId: claimed.device.id,
  1220. deviceName: claimed.device.name,
  1221. token: claimed.token,
  1222. scopes: claimed.scopes,
  1223. serverUrl: `${origin}/node/${claimed.device.id}`,
  1224. })
  1225. })
  1226. router.get('/api/pair/v1/capabilities', (ctx) => {
  1227. json(ctx, 200, {
  1228. protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
  1229. serviceType: 'yaoyao-web',
  1230. nodeId: dependencies.pairings.nodeID,
  1231. fingerprint: dependencies.pairings.fingerprint,
  1232. scopes: [...DEFAULT_NODE_SCOPES],
  1233. features: ['bots', 'bot-group', 'native-group-worker', 'history'],
  1234. })
  1235. })
  1236. router.delete('/api/pair/v1/devices/:deviceID', async (ctx) => {
  1237. const token = bearerToken(ctx.get('authorization'))
  1238. const cookies = dependencies.pairings.authorize(ctx.params.deviceID, token)
  1239. try {
  1240. await dependencies.upstream.request('/auth/logout', new CookieJar(cookies), {
  1241. method: 'POST', clientAddress: ctx.req.socket.remoteAddress,
  1242. })
  1243. } catch {
  1244. // The paired bearer is revoked locally even if upstream logout fails.
  1245. }
  1246. if (!dependencies.pairings.revoke(ctx.params.deviceID)) {
  1247. throw new HttpError(404, 'Paired device was not found', 'paired_device_not_found')
  1248. }
  1249. json(ctx, 200, { ok: true })
  1250. })
  1251. router.get('/node/:deviceID/api/node-files', (ctx) => {
  1252. const token = bearerToken(ctx.get('authorization'))
  1253. dependencies.pairings.authorize(
  1254. ctx.params.deviceID,
  1255. token,
  1256. 'history.read',
  1257. )
  1258. const value = typeof ctx.query.path === 'string' ? ctx.query.path : ''
  1259. sendLocalMedia(
  1260. ctx,
  1261. pairedLocalMediaPath(dependencies.config, value),
  1262. )
  1263. })
  1264. router.get('/node/:deviceID/api/profile-identities', async (ctx) => {
  1265. const token = bearerToken(ctx.get('authorization'))
  1266. dependencies.pairings.authorize(
  1267. ctx.params.deviceID,
  1268. token,
  1269. 'agents.read',
  1270. )
  1271. json(ctx, 200, { profiles: await dependencies.profileIdentities.load() })
  1272. })
  1273. router.all('/node/:deviceID/api/*nodePath', async (ctx) => {
  1274. const rawPath = Array.isArray(ctx.params.nodePath)
  1275. ? ctx.params.nodePath.join('/')
  1276. : ctx.params.nodePath
  1277. const path = pairedProxyPath(rawPath)
  1278. const scope = pairedProxyScope(path.slice('/api'.length), ctx.method)
  1279. const token = bearerToken(ctx.get('authorization'))
  1280. const cookieHeader = dependencies.pairings.authorize(
  1281. ctx.params.deviceID,
  1282. token,
  1283. scope,
  1284. )
  1285. if (ctx.querystring.length > 8_192) {
  1286. throw new HttpError(414, 'Paired node query is too long', 'node_query_too_long')
  1287. }
  1288. const jar = new CookieJar(cookieHeader)
  1289. const isWorkerAttachment = ctx.method === 'POST'
  1290. && /\/plugins\/yaoyao\/v1\/node-worker\/sessions\/[^/]+\/attachments$/.test(path)
  1291. && ctx.get('content-type').split(';', 1)[0]?.trim().toLowerCase() === 'application/octet-stream'
  1292. let rawBody: BodyInit | undefined
  1293. let requestHeaders: Record<string, string> = {
  1294. 'x-yaoyao-node-client': ctx.params.deviceID,
  1295. }
  1296. if (isWorkerAttachment) {
  1297. const encodedName = ctx.get('x-file-name-b64')
  1298. const mimeType = ctx.get('x-mime-type') || 'application/octet-stream'
  1299. if (!/^[A-Za-z0-9_-]{2,512}$/.test(encodedName)
  1300. || !/^[\x20-\x7e]{1,200}$/.test(mimeType)) {
  1301. throw new HttpError(400, 'Paired node attachment headers are invalid', 'invalid_node_attachment')
  1302. }
  1303. const buffer = await boundedRawBody(ctx, 25 * 1_024 * 1_024)
  1304. rawBody = buffer.buffer.slice(
  1305. buffer.byteOffset,
  1306. buffer.byteOffset + buffer.byteLength,
  1307. ) as ArrayBuffer
  1308. requestHeaders = {
  1309. ...requestHeaders,
  1310. 'content-type': 'application/octet-stream',
  1311. 'x-file-name-b64': encodedName,
  1312. 'x-mime-type': mimeType,
  1313. }
  1314. }
  1315. const response = await dependencies.upstream.request(path, jar, {
  1316. method: ctx.method,
  1317. search: new URLSearchParams(ctx.querystring),
  1318. body: ['GET', 'HEAD'].includes(ctx.method) || isWorkerAttachment
  1319. ? undefined : optionalBody(ctx),
  1320. rawBody,
  1321. headers: requestHeaders,
  1322. clientAddress: path === '/api/auth/ws-ticket'
  1323. ? undefined : ctx.req.socket.remoteAddress,
  1324. })
  1325. dependencies.pairings.updateCookies(ctx.params.deviceID, jar.header)
  1326. sendUpstreamResponse(ctx, response, jar)
  1327. })
  1328. // Historical messages can contain Markdown links such as
  1329. // /Users/<owner>/Agents/<path>. Map only the configured media root, never
  1330. // an arbitrary local filesystem path.
  1331. router.get('/Users/:owner/Agents/*filePath', async (ctx) => {
  1332. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  1333. throw new HttpError(409, '本地媒体只支持回环 Hermes 上游', 'remote_local_media_disabled')
  1334. }
  1335. if (ctx.params.owner !== dependencies.config.mediaOwner) {
  1336. throw new HttpError(404, '本地文件不存在', 'local_media_not_found')
  1337. }
  1338. await withJar(ctx, async (jar) => {
  1339. await requireGatewayAuthentication(ctx, dependencies, jar)
  1340. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  1341. sendLocalMedia(ctx, localMediaPath(dependencies.config.mediaRoot, filePath))
  1342. })
  1343. })
  1344. router.get('/Users/:owner/.hermes/attachments/*filePath', async (ctx) => {
  1345. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  1346. throw new HttpError(409, '本地附件只支持回环 Hermes 上游', 'remote_local_media_disabled')
  1347. }
  1348. if (ctx.params.owner !== dependencies.config.mediaOwner) {
  1349. throw new HttpError(404, '本地附件不存在', 'local_media_not_found')
  1350. }
  1351. await withJar(ctx, async (jar) => {
  1352. await requireGatewayAuthentication(ctx, dependencies, jar)
  1353. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  1354. sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
  1355. })
  1356. })
  1357. router.get('/Users/:owner/.hermes/images/*filePath', async (ctx) => {
  1358. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  1359. throw new HttpError(409, '本地图片只支持回环 Hermes 上游', 'remote_local_media_disabled')
  1360. }
  1361. if (ctx.params.owner !== dependencies.config.mediaOwner) {
  1362. throw new HttpError(404, '本地图片不存在', 'local_media_not_found')
  1363. }
  1364. await withJar(ctx, async (jar) => {
  1365. await requireGatewayAuthentication(ctx, dependencies, jar)
  1366. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  1367. sendLocalMedia(ctx, localMediaPath(dependencies.config.imagesRoot, filePath))
  1368. })
  1369. })
  1370. router.get('/attachments/*filePath', async (ctx) => {
  1371. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  1372. throw new HttpError(409, '本地附件只支持回环 Hermes 上游', 'remote_local_media_disabled')
  1373. }
  1374. await withJar(ctx, async (jar) => {
  1375. await requireGatewayAuthentication(ctx, dependencies, jar)
  1376. const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
  1377. sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
  1378. })
  1379. })
  1380. router.get('/readyz', async (ctx) => {
  1381. ctx.set('Cache-Control', 'no-store')
  1382. try {
  1383. const response = await dependencies.upstream.request(
  1384. '/api/status',
  1385. new CookieJar(''),
  1386. { maxResponseBytes: 2 * 1_024 * 1_024 },
  1387. )
  1388. const reachable = response.status >= 200 && response.status < 500
  1389. json(ctx, reachable ? 200 : 503, {
  1390. ok: reachable,
  1391. upstream: reachable ? 'reachable' : 'unavailable',
  1392. })
  1393. } catch {
  1394. json(ctx, 503, { ok: false, upstream: 'unavailable' })
  1395. }
  1396. })
  1397. router.get('/api/app/bootstrap', async (ctx) => {
  1398. await bootstrap(ctx, dependencies)
  1399. })
  1400. router.post('/api/app/login', async (ctx) => {
  1401. await login(ctx, dependencies)
  1402. })
  1403. router.post('/api/app/logout', async (ctx) => {
  1404. dependencies.auth.logout(ctx)
  1405. json(ctx, 200, { ok: true, csrfToken: dependencies.csrf.issue(ctx, true) })
  1406. })
  1407. router.put('/api/app/account/credentials', async (ctx) => {
  1408. const request = body(ctx)
  1409. const currentPassword = typeof request.currentPassword === 'string' ? request.currentPassword : ''
  1410. const newPassword = typeof request.newPassword === 'string' ? request.newPassword : ''
  1411. const username = typeof request.username === 'string' ? request.username : undefined
  1412. const user = dependencies.auth.changeCredentials(ctx, currentPassword, newPassword, username)
  1413. json(ctx, 200, { user, mustChangePassword: false, csrfToken: dependencies.csrf.issue(ctx, true) })
  1414. })
  1415. router.get('/api/app/admin/users', (ctx) => {
  1416. const admin = dependencies.auth.requireAdmin(ctx)
  1417. json(ctx, 200, { items: dependencies.auth.list(admin) })
  1418. })
  1419. router.post('/api/app/admin/users', (ctx) => {
  1420. const admin = dependencies.auth.requireAdmin(ctx)
  1421. const request = body(ctx)
  1422. const username = typeof request.username === 'string' ? request.username : ''
  1423. const password = typeof request.password === 'string' ? request.password : ''
  1424. json(ctx, 201, dependencies.auth.create(admin, username, password))
  1425. })
  1426. router.patch('/api/app/admin/users/:userID', (ctx) => {
  1427. const admin = dependencies.auth.requireAdmin(ctx)
  1428. const request = body(ctx)
  1429. const userID = canonicalUUID(ctx.params.userID, 'user ID')
  1430. const updated = dependencies.auth.updateUser(admin, userID, {
  1431. enabled: typeof request.enabled === 'boolean' ? request.enabled : undefined,
  1432. password: typeof request.password === 'string' ? request.password : undefined,
  1433. })
  1434. if (request.enabled === false || typeof request.password === 'string') {
  1435. bestEffortRemoveUserPush(ctx, dependencies, userID)
  1436. }
  1437. json(ctx, 200, updated)
  1438. })
  1439. router.delete('/api/app/admin/users/:userID', (ctx) => {
  1440. const admin = dependencies.auth.requireAdmin(ctx)
  1441. const userID = canonicalUUID(ctx.params.userID, 'user ID')
  1442. dependencies.auth.deleteUser(admin, userID)
  1443. bestEffortRemoveUserPush(ctx, dependencies, userID)
  1444. json(ctx, 200, { ok: true })
  1445. })
  1446. router.put('/api/app/admin/upstream-credentials', async (ctx) => {
  1447. const admin = dependencies.auth.requireAdmin(ctx)
  1448. const request = body(ctx)
  1449. const username = typeof request.username === 'string' ? request.username : ''
  1450. const password = typeof request.password === 'string' ? request.password : ''
  1451. await dependencies.upstreamSession.verify({ username, password })
  1452. dependencies.auth.setUpstreamCredentials(admin, { username, password })
  1453. json(ctx, 200, { ok: true })
  1454. })
  1455. router.get('/api/app/admin/model-services', async (ctx) => {
  1456. await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {
  1457. search: searchFrom(ctx, ['profile']),
  1458. })
  1459. })
  1460. router.post('/api/app/admin/model-services', async (ctx) => {
  1461. await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {
  1462. method: 'POST', search: searchFrom(ctx, ['profile']), requestBody: body(ctx),
  1463. })
  1464. })
  1465. router.post('/api/app/admin/model-services/validate', async (ctx) => {
  1466. await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints/validate', {
  1467. method: 'POST', requestBody: body(ctx),
  1468. })
  1469. })
  1470. router.post('/api/app/admin/model-services/:serviceID/activate', async (ctx) => {
  1471. const id = safeIdentifier(ctx.params.serviceID, 'model service ID')
  1472. await proxyAdminFeature(ctx, dependencies, `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, {
  1473. method: 'POST', search: searchFrom(ctx, ['profile']), requestBody: optionalBody(ctx) ?? {},
  1474. })
  1475. })
  1476. router.delete('/api/app/admin/model-services/:serviceID', async (ctx) => {
  1477. const id = safeIdentifier(ctx.params.serviceID, 'model service ID')
  1478. await proxyAdminFeature(ctx, dependencies, `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, {
  1479. method: 'DELETE', search: searchFrom(ctx, ['profile']),
  1480. })
  1481. })
  1482. router.get('/api/app/admin/legacy-model-services', async (ctx) => {
  1483. dependencies.auth.requireAdmin(ctx)
  1484. await withJar(ctx, async (jar) => {
  1485. const search = searchFrom(ctx, ['profile'])
  1486. const [configResponse, optionsResponse] = await Promise.all([
  1487. dependencies.upstream.request('/api/config', jar, { search }),
  1488. dependencies.upstream.request('/api/model/options', jar, {
  1489. search: new URLSearchParams([...search, ['explicit_only', 'true']]),
  1490. }),
  1491. ])
  1492. if ([configResponse.status, optionsResponse.status].some(status => status === 404 || status === 405)) {
  1493. throw new HttpError(501, '当前上游版本不支持旧版模型服务管理', 'upstream_feature_unsupported')
  1494. }
  1495. const config = requireSuccess(configResponse)
  1496. const options = requireSuccess(optionsResponse)
  1497. const currentProvider = typeof options.provider === 'string' ? options.provider.toLocaleLowerCase() : ''
  1498. const entries = Array.isArray(config.custom_providers) ? config.custom_providers : []
  1499. const items = entries.flatMap(raw => {
  1500. if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
  1501. const entry = raw as JsonObject
  1502. const name = typeof entry.name === 'string' ? entry.name.trim() : ''
  1503. const baseURL = typeof entry.base_url === 'string' ? entry.base_url.trim() : ''
  1504. const model = typeof entry.model === 'string' ? entry.model.trim() : ''
  1505. if (!name || !baseURL || !model) return []
  1506. const rawModels = entry.models
  1507. const models = Array.isArray(rawModels)
  1508. ? rawModels.map(String).map(value => value.trim()).filter(Boolean)
  1509. : rawModels && typeof rawModels === 'object'
  1510. ? Object.keys(rawModels).filter(value => !value.startsWith('__'))
  1511. : [model]
  1512. const modelConfig = rawModels && typeof rawModels === 'object' && !Array.isArray(rawModels)
  1513. ? (rawModels as JsonObject)[model]
  1514. : undefined
  1515. const contextLength = typeof entry.context_length === 'number' ? entry.context_length
  1516. : modelConfig && typeof modelConfig === 'object' && !Array.isArray(modelConfig)
  1517. && typeof (modelConfig as JsonObject).context_length === 'number'
  1518. ? (modelConfig as JsonObject).context_length as number
  1519. : undefined
  1520. const id = `custom:${name}`
  1521. return [{
  1522. id, name, base_url: baseURL, model, models: [...new Set([...models, model])],
  1523. ...(contextLength ? { context_length: contextLength } : {}),
  1524. discover_models: entry.models_discovered === true,
  1525. has_api_key: Boolean(entry.key_env || entry.api_key),
  1526. can_edit_api_key: typeof entry.key_env === 'string' && Boolean(entry.key_env.trim()),
  1527. is_current: currentProvider === id.toLocaleLowerCase(),
  1528. source: 'legacy',
  1529. }]
  1530. })
  1531. json(ctx, 200, { items })
  1532. })
  1533. })
  1534. router.put('/api/app/admin/legacy-model-services/:serviceID', async (ctx) => {
  1535. dependencies.auth.requireAdmin(ctx)
  1536. const serviceID = safeIdentifier(ctx.params.serviceID, 'legacy model service ID')
  1537. const request = body(ctx)
  1538. const baseURL = typeof request.base_url === 'string' ? request.base_url.trim().replace(/\/$/, '') : ''
  1539. const model = typeof request.model === 'string' ? request.model.trim() : ''
  1540. const models = Array.isArray(request.models)
  1541. ? [...new Set(request.models.map(String).map(value => value.trim()).filter(Boolean))]
  1542. : []
  1543. if (!/^https?:\/\//i.test(baseURL)) throw new HttpError(400, 'Base URL 必须使用 http 或 https', 'invalid_model_service')
  1544. if (!model) throw new HttpError(400, '默认模型不能为空', 'invalid_model_service')
  1545. if (!models.includes(model)) models.push(model)
  1546. const contextLength = typeof request.context_length === 'number' && Number.isInteger(request.context_length) && request.context_length > 0
  1547. ? request.context_length : undefined
  1548. await withJar(ctx, async (jar) => {
  1549. const search = searchFrom(ctx, ['profile'])
  1550. const [configResponse, optionsResponse] = await Promise.all([
  1551. dependencies.upstream.request('/api/config', jar, { search }),
  1552. dependencies.upstream.request('/api/model/options', jar, {
  1553. search: new URLSearchParams([...search, ['explicit_only', 'true']]),
  1554. }),
  1555. ])
  1556. const config = requireSuccess(configResponse)
  1557. const options = requireSuccess(optionsResponse)
  1558. const providers = Array.isArray(config.custom_providers) ? [...config.custom_providers] : []
  1559. const index = providers.findIndex(raw => {
  1560. if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
  1561. const name = typeof (raw as JsonObject).name === 'string' ? String((raw as JsonObject).name) : ''
  1562. return `custom:${name}`.toLocaleLowerCase() === serviceID.toLocaleLowerCase()
  1563. })
  1564. if (index < 0) throw new HttpError(404, '旧版模型服务不存在', 'model_service_not_found')
  1565. const current = providers[index] as JsonObject
  1566. const existingModels = current.models && typeof current.models === 'object' && !Array.isArray(current.models)
  1567. ? current.models as JsonObject : {}
  1568. const nextModels = Object.fromEntries(models.map(id => {
  1569. const previous = existingModels[id]
  1570. const metadata: JsonObject = previous && typeof previous === 'object' && !Array.isArray(previous)
  1571. ? { ...previous as JsonObject } : {}
  1572. if (id === model && contextLength) metadata.context_length = contextLength
  1573. return [id, metadata]
  1574. }))
  1575. providers[index] = {
  1576. ...current,
  1577. base_url: baseURL,
  1578. model,
  1579. models: nextModels,
  1580. models_discovered: request.discover_models === true,
  1581. ...(contextLength ? { context_length: contextLength } : {}),
  1582. }
  1583. requireSuccess(await dependencies.upstream.request('/api/config', jar, {
  1584. method: 'PUT', search, body: { config: { custom_providers: providers }, profile: search.get('profile') || undefined },
  1585. }))
  1586. if (Object.prototype.hasOwnProperty.call(request, 'api_key')) {
  1587. const keyEnvironment = typeof current.key_env === 'string' ? current.key_env.trim() : ''
  1588. if (!keyEnvironment) throw new HttpError(409, '该旧版服务没有可管理的密钥变量', 'model_service_key_unmanaged')
  1589. const apiKey = typeof request.api_key === 'string' ? request.api_key.trim() : ''
  1590. requireSuccess(await dependencies.upstream.request('/api/env', jar, {
  1591. method: apiKey ? 'PUT' : 'DELETE', search,
  1592. body: apiKey ? { key: keyEnvironment, value: apiKey, profile: search.get('profile') || undefined }
  1593. : { key: keyEnvironment, profile: search.get('profile') || undefined },
  1594. }))
  1595. }
  1596. const currentProvider = typeof options.provider === 'string' ? options.provider.toLocaleLowerCase() : ''
  1597. if (currentProvider === serviceID.toLocaleLowerCase()) {
  1598. requireSuccess(await dependencies.upstream.request('/api/model/set', jar, {
  1599. method: 'POST', search,
  1600. body: { scope: 'main', provider: serviceID, model, base_url: baseURL, confirm_expensive_model: true, profile: search.get('profile') || undefined },
  1601. }))
  1602. }
  1603. json(ctx, 200, { ok: true })
  1604. })
  1605. })
  1606. router.get('/api/app/admin/duplex-voice', async (ctx) => {
  1607. await proxyAdminFeature(ctx, dependencies, '/api/plugins/yaoyao/voice/settings')
  1608. })
  1609. router.put('/api/app/admin/duplex-voice', async (ctx) => {
  1610. await proxyAdminFeature(ctx, dependencies, '/api/plugins/yaoyao/voice/settings', {
  1611. method: 'PUT', requestBody: body(ctx),
  1612. })
  1613. })
  1614. router.get('/api/app/system/update/status', async (ctx) => {
  1615. await withJar(ctx, async (jar) => {
  1616. await requireGatewayAuthentication(ctx, dependencies, jar)
  1617. const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
  1618. json(ctx, 200, systemUpdateStatusForRequest(
  1619. ctx,
  1620. dependencies,
  1621. dependencies.updates.status(installedPluginVersion),
  1622. ))
  1623. })
  1624. })
  1625. router.get('/api/app/system/push-status', (ctx) => {
  1626. dependencies.auth.requireAdmin(ctx)
  1627. json(ctx, 200, {
  1628. ...dependencies.push.status(),
  1629. topic: dependencies.push.capabilities().topic,
  1630. })
  1631. })
  1632. router.post('/api/app/system/update/check', async (ctx) => {
  1633. await withJar(ctx, async (jar) => {
  1634. await requireGatewayAuthentication(ctx, dependencies, jar)
  1635. const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
  1636. try {
  1637. json(ctx, 200, systemUpdateStatusForRequest(
  1638. ctx,
  1639. dependencies,
  1640. await dependencies.updates.check(installedPluginVersion),
  1641. ))
  1642. } catch (error) {
  1643. throw updateFailure(error)
  1644. }
  1645. })
  1646. })
  1647. router.post('/api/app/system/update/apply', async (ctx) => {
  1648. await withJar(ctx, async (jar) => {
  1649. await requireGatewayAuthentication(ctx, dependencies, jar)
  1650. requireLocalSystemUpdate(ctx, dependencies)
  1651. const request = body(ctx)
  1652. const targetVersion = typeof request.targetVersion === 'string' ? request.targetVersion.trim() : ''
  1653. if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(targetVersion)) {
  1654. throw new HttpError(400, 'targetVersion 必须是有效的发布版本', 'invalid_request')
  1655. }
  1656. const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
  1657. await requireYaoyaoStorageReady(dependencies, jar, installedPluginVersion)
  1658. try {
  1659. const release = await dependencies.updates.check(installedPluginVersion)
  1660. if (!release.latest || release.latest.releaseVersion !== targetVersion) {
  1661. throw new Error('目标版本不是当前发布源的最新版本')
  1662. }
  1663. await reconcileYaoyaoPlugin(
  1664. dependencies,
  1665. jar,
  1666. ctx.req.socket.remoteAddress,
  1667. release.latest.pluginVersion,
  1668. )
  1669. json(ctx, 202, await dependencies.updates.startUpdate(targetVersion, installedPluginVersion))
  1670. } catch (error) {
  1671. throw updateFailure(error)
  1672. }
  1673. })
  1674. })
  1675. router.get('/api/app/system/update/jobs/:jobID', async (ctx) => {
  1676. await withJar(ctx, async (jar) => {
  1677. await requireGatewayAuthentication(ctx, dependencies, jar)
  1678. const job = dependencies.updates.job(ctx.params.jobID)
  1679. if (!job) throw new HttpError(404, '升级任务不存在', 'system_update_job_not_found')
  1680. json(ctx, 200, job)
  1681. })
  1682. })
  1683. router.post('/api/app/system/update/rollback', async (ctx) => {
  1684. await withJar(ctx, async (jar) => {
  1685. await requireGatewayAuthentication(ctx, dependencies, jar)
  1686. requireLocalSystemUpdate(ctx, dependencies)
  1687. try {
  1688. json(ctx, 202, dependencies.updates.startRollback())
  1689. } catch (error) {
  1690. throw updateFailure(error)
  1691. }
  1692. })
  1693. })
  1694. router.post('/api/app/plugins/yaoyao/reconcile', async (ctx) => {
  1695. await withJar(ctx, async (jar) => {
  1696. await requireGatewayAuthentication(ctx, dependencies, jar)
  1697. json(ctx, 200, await reconcileYaoyaoPlugin(
  1698. dependencies,
  1699. jar,
  1700. ctx.req.socket.remoteAddress,
  1701. dependencies.updates.currentManifest().pluginVersion,
  1702. ))
  1703. })
  1704. })
  1705. router.post('/api/app/plugins/yaoyao/install', async (ctx) => {
  1706. await withJar(ctx, async (jar) => {
  1707. await requireGatewayAuthentication(ctx, dependencies, jar)
  1708. const pluginSource = dependencies.config.yaoyaoPluginSource
  1709. ?? DEFAULT_YAOYAO_PLUGIN_SOURCE
  1710. const request = body(ctx)
  1711. if (request.force !== undefined && typeof request.force !== 'boolean') {
  1712. throw new HttpError(400, 'force must be a boolean', 'invalid_request')
  1713. }
  1714. const manifestsResponse = await dependencies.upstream.request(
  1715. '/api/dashboard/plugins',
  1716. jar,
  1717. )
  1718. if (manifestsResponse.status < 200 || manifestsResponse.status >= 300) {
  1719. sendUpstreamResponse(ctx, manifestsResponse, jar)
  1720. return
  1721. }
  1722. const manifests = parseJsonValue(manifestsResponse)
  1723. if (!Array.isArray(manifests)) {
  1724. throw new HttpError(502, 'Hermes returned an invalid plugin list', 'invalid_upstream_json')
  1725. }
  1726. const installed = manifests.some((entry) => (
  1727. entry && typeof entry === 'object' && !Array.isArray(entry)
  1728. && (entry as JsonObject).name === 'yaoyao'
  1729. ))
  1730. await requireYaoyaoStorageReady(
  1731. dependencies,
  1732. jar,
  1733. installed ? 'installed' : undefined,
  1734. )
  1735. const force = request.force === true
  1736. if (installed && !force) {
  1737. throw new HttpError(
  1738. 409,
  1739. '夭夭已经安装;升级时必须明确传入 force=true',
  1740. 'yaoyao_force_required',
  1741. )
  1742. }
  1743. const installResponse = await dependencies.upstream.request(
  1744. '/api/dashboard/agent-plugins/install',
  1745. jar,
  1746. {
  1747. method: 'POST',
  1748. body: {
  1749. identifier: pluginSource,
  1750. force,
  1751. enable: true,
  1752. },
  1753. clientAddress: ctx.req.socket.remoteAddress,
  1754. },
  1755. )
  1756. if (installResponse.status < 200 || installResponse.status >= 300) {
  1757. sendUpstreamResponse(ctx, installResponse, jar)
  1758. return
  1759. }
  1760. const installedResult = parseJson(installResponse)
  1761. json(ctx, 200, {
  1762. ...installedResult,
  1763. source: pluginSource,
  1764. restarted: false,
  1765. restartRequired: false,
  1766. })
  1767. })
  1768. })
  1769. router.post('/api/app/realtime-leases', async (ctx) => {
  1770. await withJar(ctx, (jar) => issueLease(ctx, dependencies, jar))
  1771. })
  1772. router.get('/api/app/profiles', async (ctx) => {
  1773. await withJar(ctx, async (jar) => {
  1774. const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
  1775. if (profilesResponse.status < 200 || profilesResponse.status >= 300) {
  1776. sendUpstreamResponse(ctx, profilesResponse, jar)
  1777. return
  1778. }
  1779. const pluginProfilesResponse = await dependencies.upstream.request('/api/plugins/yaoyao/profiles', jar)
  1780. const profiles = profilesWithHermesBotNames(
  1781. normalizedProfiles(parseJson(profilesResponse)),
  1782. pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300
  1783. ? parseJson(pluginProfilesResponse)
  1784. : undefined,
  1785. )
  1786. json(ctx, 200, { profiles })
  1787. })
  1788. })
  1789. router.get('/api/app/models', async (ctx) => {
  1790. const search = searchFrom(ctx, ['profile'])
  1791. search.set('explicit_only', 'true')
  1792. await proxy(ctx, dependencies.upstream, '/api/model/options', { search })
  1793. })
  1794. router.get('/api/app/sessions/search', async (ctx) => {
  1795. await searchSessions(ctx, dependencies)
  1796. })
  1797. router.get('/api/app/sessions/unread', async (ctx) => {
  1798. const search = searchFrom(ctx, ['profile'])
  1799. await proxyOptionalUnread(
  1800. ctx,
  1801. dependencies.upstream,
  1802. '/api/session-unread',
  1803. { search },
  1804. { profile: search.get('profile') || '', total_unread: 0, sessions: [], supported: false },
  1805. )
  1806. })
  1807. router.patch('/api/app/sessions/unread/:sessionID', async (ctx) => {
  1808. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  1809. await proxyOptionalUnread(
  1810. ctx,
  1811. dependencies.upstream,
  1812. `/api/session-unread/${encodeURIComponent(id)}`,
  1813. {
  1814. method: 'PATCH',
  1815. search: searchFrom(ctx, ['profile']),
  1816. requestBody: body(ctx),
  1817. },
  1818. { ok: true, supported: false },
  1819. )
  1820. })
  1821. router.get('/api/app/sessions', async (ctx) => {
  1822. const search = searchFrom(ctx, ['limit', 'offset', 'order', 'archived', 'profile', 'source'])
  1823. search.set('exclude_sources', 'cron,ios_group')
  1824. const path = search.get('profile') ? '/api/sessions' : '/api/profiles/sessions'
  1825. await proxy(ctx, dependencies.upstream, path, { search })
  1826. })
  1827. router.get('/api/app/sessions/:sessionID/messages', async (ctx) => {
  1828. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  1829. const incoming = searchFrom(ctx, ['offset', 'limit', 'profile'])
  1830. const offset = Math.max(0, Number(incoming.get('offset') ?? '0') || 0)
  1831. const limit = Math.max(1, Math.min(500, Number(incoming.get('limit') ?? '100') || 100))
  1832. incoming.set('offset', String(offset))
  1833. incoming.set('limit', String(limit))
  1834. incoming.set('order', 'latest')
  1835. incoming.set('include_compacted', 'true')
  1836. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}/messages`, {
  1837. search: incoming,
  1838. })
  1839. })
  1840. router.get('/api/app/sessions/:sessionID', async (ctx) => {
  1841. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  1842. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
  1843. search: searchFrom(ctx, ['profile']),
  1844. })
  1845. })
  1846. router.patch('/api/app/sessions/:sessionID', async (ctx) => {
  1847. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  1848. const search = searchFrom(ctx, ['profile'])
  1849. const request = body(ctx)
  1850. const profile = search.get('profile')
  1851. if (profile) request.profile = profile
  1852. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
  1853. method: 'PATCH',
  1854. search,
  1855. requestBody: request,
  1856. })
  1857. })
  1858. router.delete('/api/app/sessions/:sessionID', async (ctx) => {
  1859. const id = safeIdentifier(ctx.params.sessionID, 'session ID')
  1860. await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
  1861. method: 'DELETE',
  1862. search: searchFrom(ctx, ['profile']),
  1863. })
  1864. })
  1865. router.get('/api/app/groups/capabilities', async (ctx) => {
  1866. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/capabilities')
  1867. })
  1868. router.get('/api/app/groups/nodes', async (ctx) => {
  1869. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/nodes')
  1870. })
  1871. router.post('/api/app/groups/nodes', async (ctx) => {
  1872. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/nodes', {
  1873. method: 'POST', requestBody: body(ctx),
  1874. })
  1875. })
  1876. router.post('/api/app/groups/nodes/pair', async (ctx) => {
  1877. dependencies.auth.requireAdmin(ctx)
  1878. const request = body(ctx)
  1879. const qrPayload = typeof request.qrPayload === 'string' ? request.qrPayload : ''
  1880. if (!qrPayload || qrPayload.length > 4_096) {
  1881. throw new HttpError(400, '请粘贴有效的 8800 配对码', 'invalid_pairing_code')
  1882. }
  1883. json(ctx, 201, await addPairedChildNode(
  1884. qrPayload,
  1885. typeof request.name === 'string' ? request.name : undefined,
  1886. dependencies,
  1887. ))
  1888. })
  1889. router.post('/api/app/groups/nodes/refresh-identities', async (ctx) => {
  1890. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/nodes/refresh-identities', {
  1891. method: 'POST', requestBody: {},
  1892. })
  1893. })
  1894. router.delete('/api/app/groups/nodes/:nodeID', async (ctx) => {
  1895. const nodeID = canonicalUUID(ctx.params.nodeID, 'node ID')
  1896. await proxy(
  1897. ctx,
  1898. dependencies.upstream,
  1899. `/api/plugins/yaoyao/v1/nodes/${nodeID}`,
  1900. { method: 'DELETE', requestBody: body(ctx) },
  1901. )
  1902. })
  1903. router.get('/api/app/groups/rooms', async (ctx) => {
  1904. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
  1905. search: searchFrom(ctx, ['limit', 'cursor', 'archived']),
  1906. })
  1907. })
  1908. router.get('/api/app/groups/topics/pinned', async (ctx) => {
  1909. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/topics/pinned', {
  1910. search: searchFrom(ctx, ['limit']),
  1911. })
  1912. })
  1913. router.get('/api/app/groups/topics', async (ctx) => {
  1914. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/topics', {
  1915. search: searchFrom(ctx, ['limit', 'cursor']),
  1916. })
  1917. })
  1918. router.post('/api/app/groups/rooms', async (ctx) => {
  1919. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
  1920. method: 'POST', requestBody: body(ctx),
  1921. })
  1922. })
  1923. router.get('/api/app/groups/rooms/:roomID', async (ctx) => {
  1924. await proxy(ctx, dependencies.upstream, groupPath(ctx))
  1925. })
  1926. for (const method of ['patch', 'delete'] as const) {
  1927. router[method]('/api/app/groups/rooms/:roomID', async (ctx) => {
  1928. await proxy(ctx, dependencies.upstream, groupPath(ctx), {
  1929. method: method.toUpperCase(), requestBody: body(ctx),
  1930. })
  1931. })
  1932. }
  1933. router.post('/api/app/groups/rooms/:roomID/restore', async (ctx) => {
  1934. await proxy(ctx, dependencies.upstream, groupPath(ctx, '/restore'), {
  1935. method: 'POST', requestBody: body(ctx),
  1936. })
  1937. })
  1938. router.post('/api/app/groups/rooms/:roomID/agents', async (ctx) => {
  1939. await proxy(ctx, dependencies.upstream, groupPath(ctx, '/agents'), {
  1940. method: 'POST', requestBody: body(ctx),
  1941. })
  1942. })
  1943. for (const method of ['patch', 'delete'] as const) {
  1944. router[method]('/api/app/groups/rooms/:roomID/agents/:agentID', async (ctx) => {
  1945. const agentID = canonicalUUID(ctx.params.agentID, 'agent ID')
  1946. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/agents/${agentID}`), {
  1947. method: method.toUpperCase(), requestBody: body(ctx),
  1948. })
  1949. })
  1950. }
  1951. router.post('/api/app/groups/rooms/:roomID/agents/:agentID/interrupt', async (ctx) => {
  1952. const agentID = canonicalUUID(ctx.params.agentID, 'agent ID')
  1953. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/agents/${agentID}/interrupt`), {
  1954. method: 'POST', requestBody: body(ctx),
  1955. })
  1956. })
  1957. router.get('/api/app/groups/rooms/:roomID/topics', async (ctx) => {
  1958. await proxy(ctx, dependencies.upstream, groupPath(ctx, '/topics'), {
  1959. search: searchFrom(ctx, ['limit', 'cursor', 'archived']),
  1960. })
  1961. })
  1962. router.patch('/api/app/groups/rooms/:roomID/topics/:topicID', async (ctx) => {
  1963. const topicID = canonicalUUID(ctx.params.topicID, 'topic ID')
  1964. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/topics/${topicID}`), {
  1965. method: 'PATCH', requestBody: body(ctx),
  1966. })
  1967. })
  1968. router.delete('/api/app/groups/rooms/:roomID/topics/:topicID', async (ctx) => {
  1969. const topicID = canonicalUUID(ctx.params.topicID, 'topic ID')
  1970. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/topics/${topicID}`), {
  1971. method: 'DELETE', requestBody: body(ctx),
  1972. })
  1973. })
  1974. router.post('/api/app/groups/rooms/:roomID/topics/:topicID/restore', async (ctx) => {
  1975. const topicID = canonicalUUID(ctx.params.topicID, 'topic ID')
  1976. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/topics/${topicID}/restore`), {
  1977. method: 'POST', requestBody: body(ctx),
  1978. })
  1979. })
  1980. router.patch('/api/app/groups/rooms/:roomID/topics/:topicID/read', async (ctx) => {
  1981. const topicID = canonicalUUID(ctx.params.topicID, 'topic ID')
  1982. await proxy(ctx, dependencies.upstream, groupPath(ctx, `/topics/${topicID}/read`), {
  1983. method: 'PATCH', requestBody: body(ctx),
  1984. })
  1985. })
  1986. router.get('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
  1987. await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
  1988. search: searchFrom(ctx, ['topicId', 'beforeSeq', 'afterSeq', 'limit']),
  1989. })
  1990. })
  1991. router.post('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
  1992. const localUser = dependencies.auth.require(ctx)
  1993. const subscribedRoomID = canonicalUUID(ctx.params.roomID, 'room ID')
  1994. const request = body(ctx)
  1995. const uploadIds = Array.isArray(request.uploadIds)
  1996. ? request.uploadIds.map((value) => String(value))
  1997. : []
  1998. const upstreamBody = { ...request }
  1999. delete upstreamBody.uploadIds
  2000. if (uploadIds.length) {
  2001. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  2002. throw new HttpError(409, 'Group attachments require a loopback Hermes upstream', 'remote_upload_disabled')
  2003. }
  2004. const accountKey = requestAccountKey(ctx.req)
  2005. const records = dependencies.uploads.records(uploadIds, accountKey)
  2006. const content = typeof upstreamBody.content === 'string' ? upstreamBody.content.trim() : ''
  2007. upstreamBody.content = [content, uploadMarkdown(records)].filter(Boolean).join('\n\n')
  2008. await withJar(ctx, async (jar) => {
  2009. const response = await dependencies.upstream.request(groupPath(ctx, '/messages'), jar, {
  2010. method: 'POST', body: upstreamBody,
  2011. })
  2012. sendUpstreamResponse(ctx, response, jar)
  2013. if (response.status >= 200 && response.status < 300) {
  2014. dependencies.uploads.markReferenced(uploadIds, accountKey)
  2015. bestEffortAutoSubscribe(
  2016. ctx,
  2017. dependencies,
  2018. localUser.id,
  2019. subscribedRoomID,
  2020. groupMessageSequence(response),
  2021. )
  2022. }
  2023. })
  2024. return
  2025. }
  2026. await withJar(ctx, async (jar) => {
  2027. const response = await dependencies.upstream.request(groupPath(ctx, '/messages'), jar, {
  2028. method: 'POST', body: upstreamBody,
  2029. })
  2030. sendUpstreamResponse(ctx, response, jar)
  2031. if (response.status >= 200 && response.status < 300) {
  2032. bestEffortAutoSubscribe(
  2033. ctx,
  2034. dependencies,
  2035. localUser.id,
  2036. subscribedRoomID,
  2037. groupMessageSequence(response),
  2038. )
  2039. }
  2040. })
  2041. })
  2042. for (const action of ['approval', 'clarification'] as const) {
  2043. router.post(`/api/app/groups/rooms/:roomID/interactions/:interactionID/${action}`, async (ctx) => {
  2044. const interactionID = safeIdentifier(ctx.params.interactionID, 'interaction ID')
  2045. await proxy(
  2046. ctx,
  2047. dependencies.upstream,
  2048. groupPath(ctx, `/interactions/${encodeURIComponent(interactionID)}/${action}`),
  2049. { method: 'POST', requestBody: body(ctx) },
  2050. )
  2051. })
  2052. }
  2053. router.post('/api/app/group-uploads', async (ctx) => {
  2054. if (!isLoopbackUpstream(dependencies.config.upstream)) {
  2055. throw new HttpError(409, 'Group attachments require a loopback Hermes upstream', 'remote_upload_disabled')
  2056. }
  2057. await withJar(ctx, async (jar) => {
  2058. await requireGatewayAuthentication(ctx, dependencies, jar)
  2059. const accountKey = requestAccountKey(ctx.req)
  2060. const files = await receiveGroupUploads(ctx.req, dependencies.uploads, accountKey)
  2061. json(ctx, 201, { files })
  2062. })
  2063. })
  2064. router.get('/api/app/files', async (ctx) => {
  2065. await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/files', {
  2066. search: searchFrom(ctx, ['limit', 'cursor', 'search', 'profile', 'kind', 'sender', 'session_id']),
  2067. })
  2068. })
  2069. for (const action of ['download', 'preview'] as const) {
  2070. router.get(`/api/app/files/:fileID/${action}`, async (ctx) => {
  2071. const id = safeIdentifier(ctx.params.fileID, 'file ID')
  2072. await proxy(ctx, dependencies.upstream, `/api/plugins/yaoyao/${encodeURIComponent(id)}/download`, {
  2073. search: searchFrom(ctx, ['profile']),
  2074. requestHeaders: ctx.get('range') ? { range: ctx.get('range') } : undefined,
  2075. maxResponseBytes: 256 * 1_024 * 1_024,
  2076. })
  2077. if (action === 'preview') {
  2078. const type = ctx.response.get('content-type').split(';', 1)[0]?.trim().toLowerCase()
  2079. const activeContent = type === 'text/html'
  2080. || type === 'application/xhtml+xml'
  2081. || type === 'image/svg+xml'
  2082. || Boolean(type?.endsWith('/xml') || type?.endsWith('+xml'))
  2083. if (activeContent) {
  2084. ctx.type = 'application/octet-stream'
  2085. ctx.set('Content-Disposition', `attachment; filename="file-${id}"`)
  2086. } else {
  2087. ctx.remove('Content-Disposition')
  2088. }
  2089. }
  2090. })
  2091. }
  2092. router.all('/api/*gatewayPath', async (ctx) => {
  2093. if (ctx.path.startsWith('/api/app/') || ctx.path.startsWith('/api/pair/')) {
  2094. throw new HttpError(404, 'Route not found', 'not_found')
  2095. }
  2096. dependencies.auth.require(ctx, ctx.path === '/api/profiles')
  2097. const raw = Array.isArray(ctx.params.gatewayPath)
  2098. ? ctx.params.gatewayPath.join('/')
  2099. : ctx.params.gatewayPath
  2100. const upstreamPath = pairedProxyPath(raw)
  2101. const response = await dependencies.upstreamSession.request(upstreamPath, {
  2102. method: ctx.method,
  2103. search: new URLSearchParams(ctx.querystring),
  2104. body: ['GET', 'HEAD'].includes(ctx.method) ? undefined : optionalBody(ctx),
  2105. // Hermes binds WebSocket tickets to the request address. The relay's
  2106. // upstream connection originates from 8800 itself, so forwarding the
  2107. // phone's address here would make 9119 reject the ticket during Upgrade.
  2108. clientAddress: upstreamPath === '/api/auth/ws-ticket'
  2109. ? undefined : ctx.req.socket.remoteAddress,
  2110. maxResponseBytes: /\/plugins\/yaoyao\/v1\/nodes\/[0-9a-f-]{36}\/files$/.test(upstreamPath)
  2111. ? 256 * 1_024 * 1_024 : undefined,
  2112. })
  2113. const nativeGroupMessage = ctx.method === 'POST'
  2114. ? /^\/api\/plugins\/yaoyao\/v1\/rooms\/([0-9a-f-]{36})\/messages$/.exec(upstreamPath)
  2115. : null
  2116. if (nativeGroupMessage && response.status >= 200 && response.status < 300) {
  2117. const localUser = dependencies.auth.require(ctx)
  2118. bestEffortAutoSubscribe(
  2119. ctx,
  2120. dependencies,
  2121. localUser.id,
  2122. canonicalUUID(nativeGroupMessage[1]!, 'room ID'),
  2123. groupMessageSequence(response),
  2124. )
  2125. }
  2126. sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
  2127. })
  2128. return router
  2129. }
  2130. export function incomingCookieNames(ctx: Koa.Context): string[] {
  2131. return Object.keys(parse(ctx.get('cookie')))
  2132. }