FileExplorerAndEditor.utils.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import { BlobReader, BlobWriter, TextWriter, ZipReader } from '@zip.js/zip.js'
  2. import { FileAction, type FileActionResult, type FileData } from './FileExplorerAndEditor.types'
  3. import { formatBytes } from '@/lib/helpers'
  4. // Configuration for zip file extraction
  5. export const ZIP_EXTRACTION_CONFIG = {
  6. // Maximum total extracted size: 50MB (reasonable for edge functions)
  7. MAX_TOTAL_EXTRACTED_SIZE: 50 * 1024 * 1024, // 50MB
  8. // Maximum individual file size: 10MB
  9. MAX_INDIVIDUAL_FILE_SIZE: 10 * 1024 * 1024, // 10MB
  10. } as const
  11. export const isBinaryFile = (fileName: string): boolean => {
  12. const extension = fileName.split('.').pop()?.toLowerCase()
  13. const binaryExtensions = [
  14. 'wasm',
  15. 'jpg',
  16. 'jpeg',
  17. 'png',
  18. 'gif',
  19. 'bmp',
  20. 'ico',
  21. 'svg',
  22. 'mp3',
  23. 'mp4',
  24. 'avi',
  25. 'mov',
  26. 'zip',
  27. 'rar',
  28. '7z',
  29. 'tar',
  30. 'gz',
  31. 'bz2',
  32. 'pdf',
  33. ]
  34. return binaryExtensions.includes(extension || '')
  35. }
  36. export const getLanguageFromFileName = (fileName: string): string => {
  37. const extension = fileName.split('.').pop()?.toLowerCase()
  38. switch (extension) {
  39. case 'ts':
  40. case 'tsx':
  41. return 'typescript'
  42. case 'js':
  43. case 'jsx':
  44. return 'javascript'
  45. case 'json':
  46. return 'json'
  47. case 'html':
  48. return 'html'
  49. case 'css':
  50. return 'css'
  51. case 'md':
  52. return 'markdown'
  53. case 'csv':
  54. return 'csv'
  55. default:
  56. return 'plaintext' // Default to plaintext
  57. }
  58. }
  59. /**
  60. * Check if a file is a zip archive based on file extension
  61. */
  62. export const isZipFile = (fileName: string): boolean => {
  63. const extension = fileName.split('.').pop()?.toLowerCase()
  64. return extension === 'zip'
  65. }
  66. /**
  67. * Extract files from a zip archive
  68. * Returns an array of extracted files with their full paths as names (flat structure)
  69. */
  70. export const extractZipFile = async (
  71. zipFile: File,
  72. onProgress?: (current: number, total: number) => void
  73. ): Promise<{ name: string; content: string; size: number }[]> => {
  74. const zipReader = new ZipReader(new BlobReader(zipFile))
  75. const entries = await zipReader.getEntries()
  76. const extractedFiles: { name: string; content: string; size: number }[] = []
  77. const skippedFiles: string[] = []
  78. const oversizedFiles: string[] = []
  79. const failedFiles: string[] = []
  80. let totalExtractedSize = 0
  81. // Filter out directories and process files
  82. const fileEntries = entries.filter((entry) => !entry.directory)
  83. for (let i = 0; i < fileEntries.length; i++) {
  84. const entry = fileEntries[i]
  85. const fileName = entry.filename
  86. // Report progress
  87. if (onProgress) {
  88. onProgress(i + 1, fileEntries.length)
  89. }
  90. // Skip hidden files and system files
  91. const pathParts = fileName.split('/')
  92. const hasHiddenFolder = pathParts.some((part) => part.startsWith('.') || part === '__MACOSX')
  93. if (hasHiddenFolder || fileName === '.DS_Store') {
  94. skippedFiles.push(fileName)
  95. continue
  96. }
  97. // Check individual file size
  98. const uncompressedSize = entry.uncompressedSize
  99. // Guard against undefined/NaN uncompressedSize to prevent bypass of size validation
  100. if (uncompressedSize === undefined || Number.isNaN(uncompressedSize)) {
  101. oversizedFiles.push(`${fileName} (unknown size - metadata unavailable)`)
  102. continue
  103. }
  104. if (uncompressedSize > ZIP_EXTRACTION_CONFIG.MAX_INDIVIDUAL_FILE_SIZE) {
  105. oversizedFiles.push(`${fileName} (${formatBytes(uncompressedSize)})`)
  106. continue
  107. }
  108. // Check total extracted size
  109. if (totalExtractedSize + uncompressedSize > ZIP_EXTRACTION_CONFIG.MAX_TOTAL_EXTRACTED_SIZE) {
  110. throw new Error(
  111. `Total extracted size would exceed ${formatBytes(ZIP_EXTRACTION_CONFIG.MAX_TOTAL_EXTRACTED_SIZE)}. ` +
  112. `Current: ${formatBytes(totalExtractedSize)}, ` +
  113. `Attempted to add: ${formatBytes(uncompressedSize)}`
  114. )
  115. }
  116. // Extract file content
  117. try {
  118. // Skip if entry is a directory or doesn't have getData method
  119. if (entry.directory || !entry.getData) {
  120. console.warn(`Entry ${fileName} is a directory or has no getData method, skipping`)
  121. failedFiles.push(fileName)
  122. continue
  123. }
  124. let content: string
  125. if (isBinaryFile(fileName)) {
  126. // For binary files, read as blob and convert to binary string
  127. const blob = await entry.getData(new BlobWriter())
  128. const arrayBuffer = await blob.arrayBuffer()
  129. const bytes = new Uint8Array(arrayBuffer)
  130. content = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
  131. } else {
  132. // For text files, read as text
  133. content = await entry.getData(new TextWriter())
  134. }
  135. extractedFiles.push({
  136. name: fileName, // Keep full path as file name (flat structure)
  137. content,
  138. size: uncompressedSize,
  139. })
  140. totalExtractedSize += uncompressedSize
  141. } catch (error) {
  142. console.error(`Failed to extract file ${fileName}:`, error)
  143. failedFiles.push(fileName)
  144. }
  145. }
  146. await zipReader.close()
  147. // Throw error if no valid files found
  148. if (extractedFiles.length === 0) {
  149. const reasons: string[] = []
  150. if (skippedFiles.length > 0) {
  151. reasons.push(`${skippedFiles.length} hidden/system files`)
  152. }
  153. if (oversizedFiles.length > 0) {
  154. reasons.push(`${oversizedFiles.length} oversized files`)
  155. }
  156. if (failedFiles.length > 0) {
  157. reasons.push(`${failedFiles.length} files failed to extract`)
  158. }
  159. throw new Error(
  160. `No valid files found in zip archive. ${reasons.length > 0 ? 'Skipped: ' + reasons.join(', ') : ''}`
  161. )
  162. }
  163. return extractedFiles
  164. }
  165. export const getFileAction = (
  166. fileName: string,
  167. existingFiles: FileData[],
  168. newFiles: FileData[]
  169. ): FileActionResult => {
  170. const existingIndex = existingFiles.findIndex((f) => f.name === fileName)
  171. if (existingIndex !== -1) {
  172. return { action: FileAction.REPLACE_EXISTING, index: existingIndex }
  173. }
  174. const newIndex = newFiles.findIndex((f) => f.name === fileName)
  175. if (newIndex !== -1) {
  176. return { action: FileAction.REPLACE_NEW, index: newIndex }
  177. }
  178. return { action: FileAction.CREATE_NEW }
  179. }