routes.ts 91 KB

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