routes.ts 93 KB

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