routes.ts 104 KB

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