media-references.ts 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  1. /**
  2. * Authenticated GET/HEAD /api/file reads bounded file responses through
  3. * the composed filesystem provider. Paths and MIME types do not restrict access;
  4. * the connection service authenticates requests before this handler.
  5. * @module @deepseek-ai/dsh-api-session-controller/media-references
  6. */
  7. import { isAbsolute } from 'node:path'
  8. import type { Context } from '@deepseek-ai/cordis'
  9. import type {} from '@deepseek-ai/dsh-client-connection'
  10. import type {} from '@deepseek-ai/dsh-attachment'
  11. import { FsError, type FileSystem } from '@deepseek-ai/dsh-fs'
  12. import mime from 'mime-types'
  13. const BASE_HEADERS = {
  14. 'Cache-Control': 'private, no-store',
  15. 'X-Content-Type-Options': 'nosniff',
  16. // HTML and SVG files may be opened directly on the authenticated API origin.
  17. 'Content-Security-Policy': "sandbox; default-src 'none'",
  18. }
  19. async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise<Response> {
  20. const fail = (status: number, text: string): Response =>
  21. new Response(request.method === 'HEAD' ? null : text, { status, headers: BASE_HEADERS })
  22. const path = new URL(request.url).searchParams.get('path')
  23. if (path === null || path.length === 0) return fail(400, 'missing path')
  24. if (path.includes('\0') || !isAbsolute(path)) return fail(400, 'absolute path required')
  25. try {
  26. const target = await fs.resolve(path, { signal: request.signal })
  27. const mediaType = mime.lookup(target.displayPath) || 'application/octet-stream'
  28. const headers: Record<string, string> = {
  29. ...BASE_HEADERS,
  30. 'Content-Type': mediaType,
  31. }
  32. if (request.method === 'HEAD') {
  33. const info = await fs.stat(target, request.signal)
  34. if (info === undefined) return fail(404, 'not found')
  35. if (info.type !== 'file') return fail(403, 'not a regular file')
  36. if (info.size !== undefined) {
  37. if (info.size > maxBytes) return fail(413, 'file exceeds byte limit')
  38. headers['Content-Length'] = String(info.size)
  39. }
  40. return new Response(null, { headers })
  41. }
  42. const bytes = await fs.readBytes(target, request.signal, maxBytes)
  43. headers['Content-Length'] = String(bytes.byteLength)
  44. return new Response(bytes.slice(), { headers })
  45. } catch (error: unknown) {
  46. if (!(error instanceof FsError)) throw error
  47. const statuses: Partial<Record<FsError['code'], number>> = {
  48. FS_NOT_FOUND: 404,
  49. FS_NOT_REGULAR_FILE: 403,
  50. FS_PERMISSION_DENIED: 403,
  51. FS_SANDBOX_DENIED: 403,
  52. FS_TOO_LARGE: 413,
  53. FS_ABORTED: 499,
  54. }
  55. return fail(statuses[error.code] ?? 500, error.code)
  56. }
  57. }
  58. /**
  59. * File-display contribution. The connection service supplies authentication;
  60. * `ctx.fs` supplies the execution world's paths, reads, and access policy.
  61. */
  62. export const SessionMediaReferences = {
  63. inject: ['connection', 'fs', 'attachments'],
  64. apply(ctx: Context): void {
  65. const maxBytes = ctx.attachments.imageLimits.maxImageBytes
  66. ctx.effect(() => ctx.connection.fetch.register({
  67. path: '/api/file',
  68. methods: ['GET', 'HEAD'],
  69. requestBody: 'buffered',
  70. fetch: request => serveFile(request, ctx.fs, maxBytes),
  71. }), 'session-controller: /api/file')
  72. },
  73. }