upload-url.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { z } from 'zod'
  2. /**
  3. * The URLs the app hands out for its own uploads, and nothing else.
  4. *
  5. * Every stored file URL is later turned into a path on disk and read, copied
  6. * or unlinked, so what may be stored is exactly the shape the upload routes
  7. * produce: an organisation id, a category folder and one file name. Anything
  8. * with a slash or a dot pair in the wrong place, another host, or a scheme is
  9. * refused before it reaches the database.
  10. */
  11. export const UPLOAD_CATEGORIES = [
  12. 'vehicles',
  13. 'inventory',
  14. 'services',
  15. 'logos',
  16. 'email-logos',
  17. 'email-images',
  18. 'quotes',
  19. 'portal',
  20. 'tire-hotel',
  21. 'email',
  22. ] as const
  23. export type UploadCategory = (typeof UPLOAD_CATEGORIES)[number]
  24. const UPLOAD_URL =
  25. /^\/api\/(?:protected\/)?files\/([A-Za-z0-9_-]{1,64})\/([a-z-]{1,32})\/([A-Za-z0-9][A-Za-z0-9._-]{0,200})$/
  26. /**
  27. * The shape files had before uploads were served per organisation: a
  28. * category folder under public/uploads and one file name. Nothing left on
  29. * disk is written this way any more, but records that still carry one are
  30. * sent back whole when their owner edits them, so the shape stays storable.
  31. * There is no organisation in it to check; the path resolver contains it.
  32. */
  33. const LEGACY_UPLOAD_URL = /^\/uploads\/([a-z-]{1,32})\/([A-Za-z0-9][A-Za-z0-9._-]{0,200})$/
  34. export interface UploadUrlParts {
  35. organizationId: string
  36. category: string
  37. file: string
  38. }
  39. /** The parts of one of our upload URLs, or null for anything else. */
  40. export function parseUploadUrl(value: string | null | undefined): UploadUrlParts | null {
  41. const match = value ? UPLOAD_URL.exec(value) : null
  42. if (!match) return null
  43. const [, organizationId, category, file] = match
  44. if (file.includes('..') || !(UPLOAD_CATEGORIES as readonly string[]).includes(category)) {
  45. return null
  46. }
  47. return { organizationId, category, file }
  48. }
  49. /** Whether a value is a file of ours in any of the shapes we have stored. */
  50. export function isStoredUploadUrl(value: string | null | undefined): boolean {
  51. if (!value) return false
  52. if (parseUploadUrl(value)) return true
  53. const legacy = LEGACY_UPLOAD_URL.exec(value)
  54. return !!legacy && !legacy[2].includes('..')
  55. }
  56. /**
  57. * Whether a value is a picture on another site: the address a supplier's
  58. * catalogue gives for a part. It never reaches the disk, only an <img>, so
  59. * the only thing to refuse is a scheme that could run something.
  60. */
  61. export function isRemoteImageUrl(value: string | null | undefined): boolean {
  62. if (!value || value.length > 2000) return false
  63. try {
  64. const url = new URL(value)
  65. return url.protocol === 'https:' || url.protocol === 'http:'
  66. } catch {
  67. return false
  68. }
  69. }
  70. /** Whether a stored URL names one of this organisation's own uploads. */
  71. export function isOwnUploadUrl(value: string | null | undefined, organizationId: string): boolean {
  72. return parseUploadUrl(value)?.organizationId === organizationId
  73. }
  74. /**
  75. * Refuses a URL that is not one of this organisation's uploads. Empty means
  76. * "no file" and passes, since that is how a cleared image is expressed.
  77. */
  78. export function assertOwnUploadUrl(
  79. value: string | null | undefined,
  80. organizationId: string,
  81. what = 'file'
  82. ): void {
  83. if (!value) return
  84. if (!isOwnUploadUrl(value, organizationId)) {
  85. throw new Error(`The ${what} is not an upload of this workshop`)
  86. }
  87. }
  88. /** A zod string that must be one of our upload URLs. */
  89. export const uploadUrlSchema = z
  90. .string()
  91. .max(300)
  92. .refine((value) => isStoredUploadUrl(value), 'Not an upload of this workshop')
  93. /** The same, but an empty string is allowed for "no file". */
  94. export const optionalUploadUrlSchema = z
  95. .string()
  96. .max(300)
  97. .refine((value) => value === '' || isStoredUploadUrl(value), 'Not an upload of this workshop')
  98. /**
  99. * A picture that may be one of our uploads or live on another site. Part
  100. * images are the case: "fetch from supplier" keeps the catalogue's own
  101. * address rather than copying the file, and the record is saved with it.
  102. */
  103. export const imageUrlSchema = z
  104. .string()
  105. .max(2000)
  106. .refine(
  107. (value) => isStoredUploadUrl(value) || isRemoteImageUrl(value),
  108. 'Not an upload of this workshop or a web address'
  109. )
  110. const UPLOAD_PREFIXES = ['/api/protected/files/', '/api/files/']
  111. /**
  112. * Walks a parsed input and refuses any string that names an upload of
  113. * another organisation. The shape checks above say what a URL must look
  114. * like; this says whose it must be, and it runs after every parse that
  115. * stores one, so a copied URL can never point a record at a stranger's file.
  116. */
  117. export function assertOwnUploads(value: unknown, organizationId: string): void {
  118. if (typeof value === 'string') {
  119. if (UPLOAD_PREFIXES.some((prefix) => value.startsWith(prefix))) {
  120. const normalised = value.replace('/api/files/', '/api/protected/files/')
  121. if (!isOwnUploadUrl(normalised, organizationId)) {
  122. throw new Error('A file in this request is not an upload of this workshop')
  123. }
  124. }
  125. return
  126. }
  127. if (Array.isArray(value)) {
  128. for (const item of value) assertOwnUploads(item, organizationId)
  129. return
  130. }
  131. if (value && typeof value === 'object') {
  132. for (const item of Object.values(value as Record<string, unknown>)) {
  133. assertOwnUploads(item, organizationId)
  134. }
  135. }
  136. }
  137. /** The extension a stored file gets, from its validated MIME type, never from its name. */
  138. const EXTENSION_BY_TYPE: Record<string, string> = {
  139. 'image/jpeg': 'jpg',
  140. 'image/png': 'png',
  141. 'image/webp': 'webp',
  142. 'image/avif': 'avif',
  143. 'image/gif': 'gif',
  144. 'application/pdf': 'pdf',
  145. 'text/csv': 'csv',
  146. 'text/plain': 'txt',
  147. 'video/mp4': 'mp4',
  148. 'video/webm': 'webm',
  149. 'video/quicktime': 'mov',
  150. }
  151. export function extensionForType(mimeType: string, fallback = 'bin'): string {
  152. return EXTENSION_BY_TYPE[mimeType.toLowerCase()] ?? fallback
  153. }
  154. /**
  155. * Headers for serving a stored SVG. The file is offered as a download inside
  156. * a sandbox, never rendered in the app's origin, so a script inside it has
  157. * nowhere to run.
  158. */
  159. export function svgDownloadHeaders(cacheControl: string): Record<string, string> {
  160. return {
  161. 'Content-Type': 'image/svg+xml',
  162. 'Content-Disposition': 'attachment',
  163. 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
  164. 'X-Content-Type-Options': 'nosniff',
  165. 'Cache-Control': cacheControl,
  166. }
  167. }