attachments.ts 2.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. import { serverFilePath } from '@shared/serverFiles'
  2. export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
  3. /** Use the authenticated file route for uploads from any Hermes home. */
  4. export function imageAttachmentReference(raw: string, profile?: string): { path: string; url: string } | undefined {
  5. let source = raw.trim().replace(/^[`'"]|[`'"]$/g, '')
  6. let decode = true
  7. if (/^\/api\/(?:files\/(?:download|stream)|hermes\/download|media)\?/.test(source)) {
  8. source = new URL(source, 'https://local.invalid').searchParams.get('path') || ''
  9. decode = false // URLSearchParams already decoded the path.
  10. }
  11. // Encoded separators must not change the meaning of a path segment.
  12. if (/%2f|%5c|[\\\u0000-\u001f\u007f]/i.test(source)
  13. || source.split('/').some(segment => /^(?:\.|%2e){1,2}$/i.test(segment))) return
  14. const path = serverFilePath(source, decode)
  15. if (!path || path.split('/').some(segment => segment === '.')) return
  16. if (decode && !/^(?:file:|sandbox:)/i.test(source)
  17. && /^\/(?:api|assets|icons|brand|attachments|uploads|media|chat|history|conversations|kanban|files|artifacts|settings)(?:\/|$)/.test(path)) return
  18. return { path, url: `/api/files/download?${new URLSearchParams({ path, preview: '1', ...(profile ? { profile } : {}) })}` }
  19. }
  20. export interface EncodedAttachment {
  21. name: string
  22. mimeType: string
  23. size: number
  24. base64: string
  25. dataUrl: string
  26. extension: string
  27. kind: 'image' | 'pdf' | 'file'
  28. }
  29. function readAsDataURL(file: File): Promise<string> {
  30. return new Promise((resolve, reject) => {
  31. const reader = new FileReader()
  32. reader.onerror = () => reject(reader.error ?? new Error(`无法读取 ${file.name}`))
  33. reader.onload = () => resolve(String(reader.result ?? ''))
  34. reader.readAsDataURL(file)
  35. })
  36. }
  37. export async function encodeAttachment(file: File): Promise<EncodedAttachment> {
  38. if (file.size > MAX_ATTACHMENT_BYTES) throw new Error(`${file.name} 超过 25 MiB 限制`)
  39. const dataUrl = await readAsDataURL(file)
  40. const separator = dataUrl.indexOf(',')
  41. if (separator < 0) throw new Error(`${file.name} 编码失败`)
  42. const mimeType = file.type || 'application/octet-stream'
  43. return {
  44. name: file.name,
  45. mimeType,
  46. size: file.size,
  47. base64: dataUrl.slice(separator + 1),
  48. dataUrl,
  49. extension: file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : '',
  50. kind: mimeType.startsWith('image/') ? 'image' : mimeType === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') ? 'pdf' : 'file',
  51. }
  52. }