hermesMediaRoutes.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import request from 'supertest'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  8. import type { ServerConfig } from '../../src/server/config.js'
  9. import { createAuthenticatedApplication } from './authenticatedApplication.js'
  10. import { saveFileAccess } from '../../src/server/fileAccess.js'
  11. const HOST = '127.0.0.1:15300'
  12. const IMAGE_NAME = 'openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png'
  13. const IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR5kAAAAASUVORK5CYII=', 'base64')
  14. const homes: string[] = []
  15. const runtimes: ApplicationRuntime[] = []
  16. afterEach(() => {
  17. for (const runtime of runtimes.splice(0)) runtime.close()
  18. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  19. })
  20. interface UpstreamCall {
  21. url: URL
  22. method: string
  23. headers: Headers
  24. }
  25. function config(): ServerConfig {
  26. const home = mkdtempSync(join(tmpdir(), 'yaoyao-hermes-media-'))
  27. homes.push(home)
  28. saveFileAccess(home, { mode: 'all', folders: [] })
  29. return {
  30. host: '127.0.0.1', port: 15300,
  31. upstream: new URL('http://10.10.1.200:9119'),
  32. upstreamUsername: 'fixture-service', upstreamPassword: 'fixture-password',
  33. allowedHosts: new Set(), home,
  34. mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  35. mediaOwner: 'web-host-owner',
  36. allowInsecureLan: false, insecureLan: false, production: false,
  37. }
  38. }
  39. function gateway(response: () => Response = () => new Response(IMAGE, {
  40. headers: { 'content-type': 'image/png', 'content-disposition': `attachment; filename="${IMAGE_NAME}"` },
  41. })) {
  42. const calls: UpstreamCall[] = []
  43. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  44. const url = new URL(input instanceof Request ? input.url : String(input))
  45. calls.push({ url, method: init?.method ?? 'GET', headers: new Headers(init?.headers) })
  46. if (url.pathname === '/api/status') return Response.json({ auth_required: true }, {
  47. headers: { 'set-cookie': 'hermes_service=fixture-service-cookie; Path=/; HttpOnly' },
  48. })
  49. if (url.pathname === '/api/auth/me') return Response.json({ user_id: 'fixture-service' })
  50. if (url.pathname === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
  51. if (url.pathname === '/auth/password-login') return Response.json({ ok: true }, {
  52. headers: { 'set-cookie': 'hermes_service=fixture-service-cookie; Path=/; HttpOnly' },
  53. })
  54. if (url.pathname === '/api/files/download') return response()
  55. return Response.json({ error: 'Unexpected upstream route' }, { status: 404 })
  56. }) as typeof fetch
  57. return { calls, fetchImpl, downloads: () => calls.filter(call => call.url.pathname === '/api/files/download') }
  58. }
  59. function runtimeFor(fixture: ReturnType<typeof gateway>, authenticated = true): ApplicationRuntime {
  60. const runtime = (authenticated ? createAuthenticatedApplication : createApplication)({
  61. config: config(), fetchImpl: fixture.fetchImpl,
  62. })
  63. runtimes.push(runtime)
  64. return runtime
  65. }
  66. function bodyBytes(response: request.Response): Buffer {
  67. return Buffer.isBuffer(response.body) ? response.body : Buffer.from(response.text ?? '')
  68. }
  69. describe('historical Hermes media routes', () => {
  70. it.each([
  71. '/Users/samien/.hermes/workspace/gpt-6-astra-report.md',
  72. '/Users/samien/.hermes/profiles/yaoer/workspace/详细 文档.md',
  73. ])('serves generated workspace documents for preview and download: %s', async path => {
  74. const document = '# GPT-6\n\n文档正文'
  75. const fixture = gateway(() => new Response(document, {
  76. headers: { 'content-type': 'text/markdown', 'content-disposition': 'attachment; filename="report.md"' },
  77. }))
  78. const runtime = runtimeFor(fixture)
  79. const response = await request(runtime.app.callback()).get(encodeURI(path)).set('Host', HOST).expect(200)
  80. expect(response.text).toBe(document)
  81. expect(response.headers['content-type']).toMatch(/^text\/markdown/)
  82. expect(response.headers['content-disposition']).toBeUndefined()
  83. expect(response.headers['cache-control']).toContain('private, no-store')
  84. expect([...fixture.downloads()[0]!.url.searchParams]).toEqual([['path', path]])
  85. })
  86. it.each([
  87. `/Users/samien/.hermes/cache/images/${IMAGE_NAME}`,
  88. `/Users/samien/.hermes/profiles/yaoer/cache/images/${IMAGE_NAME}`,
  89. ])('serves the original generated-image URL through the remote Hermes service: %s', async path => {
  90. const fixture = gateway()
  91. const runtime = runtimeFor(fixture)
  92. const response = await request(runtime.app.callback()).get(path)
  93. .set('Host', HOST).set('Cookie', 'browser_only=must-not-forward').expect(200)
  94. expect(bodyBytes(response)).toEqual(IMAGE)
  95. expect(response.headers['content-type']).toMatch(/^image\/png/)
  96. expect(response.headers['content-disposition'] ?? '').not.toMatch(/^attachment/i)
  97. expect(response.headers['cache-control']).toContain('private')
  98. expect(response.headers['cache-control']).toContain('no-store')
  99. expect(response.headers['set-cookie']).toBeUndefined()
  100. expect(fixture.downloads()).toHaveLength(1)
  101. const download = fixture.downloads()[0]!
  102. expect(download.url.origin).toBe('http://10.10.1.200:9119')
  103. expect([...download.url.searchParams]).toEqual([['path', path]])
  104. expect(download.method).toBe('GET')
  105. expect(download.headers.get('cookie')).toContain('hermes_service=fixture-service-cookie')
  106. expect(download.headers.get('cookie')).not.toContain('browser_only')
  107. })
  108. it('decodes nested Chinese filenames once and ignores browser query overrides', async () => {
  109. const fixture = gateway()
  110. const runtime = runtimeFor(fixture)
  111. const path = '/Users/远端用户/.hermes/profiles/yaoer/cache/images/三视图/角色 图.png'
  112. const encodedPath = path.split('/').map(encodeURIComponent).join('/')
  113. await request(runtime.app.callback())
  114. .get(`${encodedPath}?path=%2Fetc%2Fpasswd&profile=forged&token=browser-secret`)
  115. .set('Host', HOST).expect(200)
  116. expect([...fixture.downloads()[0]!.url.searchParams]).toEqual([['path', path]])
  117. })
  118. it.each(['images', 'screenshots', 'attachments'])('supports the profile %s media directory', async directory => {
  119. const fixture = gateway()
  120. const runtime = runtimeFor(fixture)
  121. const path = `/Users/remote/.hermes/profiles/yaoer/${directory}/nested/result.png`
  122. await request(runtime.app.callback()).get(path).set('Host', HOST).expect(200)
  123. expect(fixture.downloads()[0]!.url.searchParams.get('path')).toBe(path)
  124. })
  125. it('preserves range requests and partial image responses', async () => {
  126. const partial = IMAGE.subarray(2, 8)
  127. const fixture = gateway(() => new Response(partial, {
  128. status: 206,
  129. headers: {
  130. 'content-type': 'image/png', 'content-disposition': 'attachment; filename="result.png"',
  131. 'accept-ranges': 'bytes', 'content-range': `bytes 2-7/${IMAGE.length}`, 'content-length': '6',
  132. },
  133. }))
  134. const runtime = runtimeFor(fixture)
  135. const response = await request(runtime.app.callback())
  136. .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
  137. .set('Host', HOST).set('Range', 'bytes=2-7').expect(206)
  138. expect(bodyBytes(response)).toEqual(partial)
  139. expect(fixture.downloads()[0]!.headers.get('range')).toBe('bytes=2-7')
  140. expect(response.headers['content-range']).toBe(`bytes 2-7/${IMAGE.length}`)
  141. expect(response.headers['accept-ranges']).toBe('bytes')
  142. expect(response.headers['content-disposition'] ?? '').not.toMatch(/^attachment/i)
  143. })
  144. it.each([
  145. '/Users/samien/.hermes/profiles/yaoer/cache/images/result.png',
  146. '/Users/samien/.hermes/workspace/report.md',
  147. ])('requires a local login before contacting Hermes: %s', async path => {
  148. const fixture = gateway()
  149. const runtime = runtimeFor(fixture, false)
  150. await request(runtime.app.callback())
  151. .get(path)
  152. .set('Host', HOST).expect(401)
  153. expect(fixture.calls).toHaveLength(0)
  154. })
  155. it.each([401, 403, 404])('preserves upstream %s without a broader file-read fallback', async status => {
  156. const fixture = gateway(() => Response.json({ detail: `upstream-${status}` }, { status }))
  157. const runtime = runtimeFor(fixture)
  158. const response = await request(runtime.app.callback())
  159. .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
  160. .set('Host', HOST).expect(status)
  161. expect(response.body).toEqual({ detail: `upstream-${status}` })
  162. expect(fixture.downloads().length).toBeGreaterThan(0)
  163. expect(fixture.calls.some(call => call.url.pathname === '/api/fs/read-data-url' || call.url.pathname === '/api/media')).toBe(false)
  164. })
  165. it('keeps active content as an attachment even beneath a media directory', async () => {
  166. const fixture = gateway(() => new Response('<svg xmlns="http://www.w3.org/2000/svg"/>', {
  167. headers: { 'content-type': 'image/svg+xml', 'content-disposition': 'inline; filename="result.svg"' },
  168. }))
  169. const runtime = runtimeFor(fixture)
  170. const response = await request(runtime.app.callback())
  171. .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.svg')
  172. .set('Host', HOST).expect(200)
  173. expect(response.headers['content-type']).toMatch(/^application\/octet-stream/)
  174. expect(response.headers['content-disposition']).toMatch(/^attachment/i)
  175. expect(response.headers['cache-control']).toContain('no-store')
  176. })
  177. it.each([
  178. '/Users/samien/.hermes/workspace/nested%2F..%2Fconfig.yaml',
  179. '/Users/samien/.hermes/workspace/result%5C.md',
  180. '/Users/samien/.hermes/workspace/result%00.md',
  181. '/Users/samien/.hermes/profiles/yaoer/config/result.png',
  182. '/Users/samien/.hermes/profiles/yaoer/cache/images/nested%2F..%2Fresult.png',
  183. '/Users/samien/.hermes/profiles/yaoer/cache/images/result%5C.png',
  184. '/Users/samien/.hermes/profiles/yaoer/cache/images/result%00.png',
  185. '/Users/samien/.hermes/profiles/yaoer%2Fother/cache/images/result.png',
  186. '/Users/samien%2Fother/.hermes/cache/images/result.png',
  187. ])('rejects unsupported directories and invalid decoded path segments: %s', async path => {
  188. const fixture = gateway()
  189. const runtime = runtimeFor(fixture)
  190. const response = await request(runtime.app.callback()).get(path).set('Host', HOST)
  191. expect([400, 404]).toContain(response.status)
  192. expect(fixture.calls).toHaveLength(0)
  193. })
  194. })