media-references.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. /**
  2. * GET/HEAD /api/file — same-origin media bytes for a local path authored in
  3. * conversation prose. Browsers cannot read Host files, so local media
  4. * destinations (images today; video/audio the same way) are rewritten to this
  5. * one route; the route re-validates every request because the rewrite itself
  6. * proves nothing.
  7. *
  8. * Policy (enforced per request, fail-closed):
  9. * - the requested path must be one absolute filesystem path;
  10. * - its canonical location must lie inside a registered workspace root
  11. * (`ctx.workspaceRegistry`); no other directory is readable;
  12. * - the file must exist and be a regular file;
  13. * - its extension must name an allowlisted media type (media bytes are never
  14. * sniffed here: the allowlist keeps non-media content out, and browsers
  15. * already reject corrupt image payloads);
  16. * - responses are private, uncached, sniff-proof, and support HTTP range
  17. * requests so `<video>`/`<audio>` can seek without buffering the file.
  18. *
  19. * The route is deliberately presentational: it never writes and follows no
  20. * redirects, returning 400/403/404/415/416 instead of falling back to any
  21. * other file-serving behavior.
  22. *
  23. * The package entry imports only `SessionMediaReferences`; the remaining
  24. * module exports exist for same-package unit tests and are not part of the
  25. * package's public API.
  26. * @module @deepseek-ai/dsh-api-session-controller/media-references
  27. */
  28. import { createReadStream } from 'node:fs'
  29. import { realpath, stat } from 'node:fs/promises'
  30. import { extname, isAbsolute, sep } from 'node:path'
  31. import { Readable } from 'node:stream'
  32. import type { Context } from '@deepseek-ai/cordis'
  33. // Cordis `ctx.connection` typing and the fetch-route contract.
  34. import type {} from '@deepseek-ai/dsh-client-connection'
  35. // Cordis `ctx.workspaceRegistry` typing.
  36. import type {} from '@deepseek-ai/dsh-workspace'
  37. /** Registered-workspace view the route reads; see the `workspaceRegistry` service. */
  38. export interface MediaReferenceRegistry {
  39. /** List registered workspaces with their canonical root directories. */
  40. list(): readonly { path: string }[]
  41. }
  42. /** Media extensions the route serves, mapped to their content types. */
  43. const MEDIA_TYPES: Readonly<Record<string, string>> = {
  44. '.png': 'image/png',
  45. '.jpg': 'image/jpeg',
  46. '.jpeg': 'image/jpeg',
  47. '.gif': 'image/gif',
  48. '.webp': 'image/webp',
  49. '.avif': 'image/avif',
  50. '.mp4': 'video/mp4',
  51. '.webm': 'video/webm',
  52. '.mov': 'video/quicktime',
  53. '.ogv': 'video/ogg',
  54. '.mp3': 'audio/mpeg',
  55. '.wav': 'audio/wav',
  56. '.ogg': 'audio/ogg',
  57. '.oga': 'audio/ogg',
  58. '.m4a': 'audio/mp4',
  59. '.aac': 'audio/aac',
  60. '.flac': 'audio/flac',
  61. }
  62. /**
  63. * The content type a file path may be served as, or undefined when the
  64. * extension is not an allowlisted media type.
  65. * @param path - Canonical file path (extension only is read).
  66. * @returns the content type, or undefined to refuse the file.
  67. */
  68. export function mediaTypeForPath(path: string): string | undefined {
  69. return MEDIA_TYPES[extname(path).toLowerCase()]
  70. }
  71. /** One resolved byte-range within a file; `full` streams the whole file. */
  72. type ByteRange =
  73. | { readonly kind: 'full' }
  74. | { readonly kind: 'partial'; readonly start: number; readonly end: number }
  75. /**
  76. * Parse one single-range `Range` header value against the file size.
  77. * @param header - Raw `Range` request header, or null when absent.
  78. * @param size - Total file size in bytes.
  79. * @returns the byte range to serve, or undefined when the header names no
  80. * satisfiable single range (the caller answers 416 with a `bytes *\/size`
  81. * Content-Range header).
  82. */
  83. export function parseByteRange(header: string | null, size: number): ByteRange | undefined {
  84. if (header === null) return { kind: 'full' }
  85. const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
  86. if (match === null) return undefined
  87. const startText = match[1]
  88. const endText = match[2]
  89. if (startText === '' && endText === '') return undefined
  90. let start: number
  91. let end: number
  92. if (startText === '') {
  93. // Suffix range: the last N bytes.
  94. const length = Number(endText)
  95. if (length === 0) return undefined
  96. start = Math.max(size - length, 0)
  97. end = size - 1
  98. } else {
  99. start = Number(startText)
  100. end = endText === '' ? size - 1 : Number(endText)
  101. }
  102. if (start > end || start >= size) return undefined
  103. return { kind: 'partial', start, end: Math.min(end, size - 1) }
  104. }
  105. /**
  106. * Serve one workspace-contained media file over the shared API channel.
  107. * @param request - Authenticated fetch-route request (GET or HEAD).
  108. * @param registry - Workspace registry; absent (or empty) denies everything.
  109. * @returns A streaming media response or a fail-closed status.
  110. */
  111. export async function serveMediaReference(
  112. request: Request,
  113. registry: MediaReferenceRegistry | undefined,
  114. ): Promise<Response> {
  115. const path = new URL(request.url).searchParams.get('path')
  116. if (path === null || path.length === 0) return new Response('missing path', { status: 400 })
  117. if (path.includes('\0') || !isAbsolute(path)) {
  118. return new Response('absolute path required', { status: 400 })
  119. }
  120. if (registry === undefined) return new Response('file serving is unavailable', { status: 403 })
  121. let canonical: string
  122. let info
  123. try {
  124. canonical = await realpath(path)
  125. info = await stat(canonical)
  126. } catch {
  127. return new Response('not found', { status: 404 })
  128. }
  129. const insideWorkspace = registry.list().some(root =>
  130. canonical === root.path || canonical.startsWith(root.path + sep))
  131. if (!insideWorkspace) return new Response('outside workspace roots', { status: 403 })
  132. if (!info.isFile()) return new Response('not a regular file', { status: 403 })
  133. const mediaType = mediaTypeForPath(canonical)
  134. if (mediaType === undefined) {
  135. return new Response('not an allowlisted media type', { status: 415 })
  136. }
  137. const range = parseByteRange(request.headers.get('range'), info.size)
  138. if (range === undefined) {
  139. const headers: Record<string, string> = {
  140. 'Content-Range': 'bytes */' + String(info.size),
  141. 'Cache-Control': 'private, no-store',
  142. 'X-Content-Type-Options': 'nosniff',
  143. }
  144. return new Response(null, { status: 416, headers })
  145. }
  146. const partial = range.kind === 'partial'
  147. const start = partial ? range.start : 0
  148. const end = partial ? range.end : info.size - 1
  149. const body: ReadableStream<Uint8Array> = Readable.toWeb(
  150. createReadStream(canonical, { start, end }),
  151. ) as ReadableStream<Uint8Array>
  152. const headers: Record<string, string> = {
  153. 'Content-Type': mediaType,
  154. 'Content-Length': String(partial ? end - start + 1 : info.size),
  155. 'Accept-Ranges': 'bytes',
  156. 'Cache-Control': 'private, no-store',
  157. 'X-Content-Type-Options': 'nosniff',
  158. }
  159. if (partial) headers['Content-Range'] = 'bytes ' + String(start) + '-' + String(end) + '/' + String(info.size)
  160. const head = request.method === 'HEAD'
  161. return new Response(head ? null : body, { status: partial ? 206 : 200, headers })
  162. }
  163. /**
  164. * `/api/file` fetch-route contribution, the media counterpart of
  165. * `SessionFileReferences`: that contribution lets the GUI discover the files
  166. * a Session references, this one lets it display referenced local media
  167. * paths as same-origin bytes. Declared injects gate activation: a host
  168. * composition without a connection service (or a workspace registry) never
  169. * activates this plugin, so the route simply does not exist there — the same
  170. * pending-until-composed posture the package's other optional contributions
  171. * use. The channel's trust fence and browser authentication apply before any
  172. * request reaches the handler.
  173. */
  174. export const SessionMediaReferences = {
  175. inject: ['connection', 'workspaceRegistry'],
  176. apply(ctx: Context): void {
  177. ctx.effect(() => ctx.connection.fetch.register({
  178. path: '/api/file',
  179. methods: ['GET', 'HEAD'],
  180. requestBody: 'buffered',
  181. fetch: request => serveMediaReference(request, ctx.workspaceRegistry),
  182. }), 'session-controller: /api/file')
  183. },
  184. }