routes.ts 97 KB

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