routes.ts 84 KB

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