routes.ts 99 KB

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