@@ -6,11 +6,20 @@ import mime from 'mime';
66import isAfter from 'date-fns/isAfter' ;
77import axios from 'axios' ;
88import slugify from 'slugify' ;
9+ import { S3Client , GetObjectCommand } from '@aws-sdk/client-s3' ;
910import Project from '../models/project' ;
1011import { User } from '../models/user' ;
1112import { resolvePathToFile } from '../utils/filePath' ;
1213import { generateFileSystemSafeName } from '../utils/generateFileSystemSafeName' ;
1314
15+ const s3Client = new S3Client ( {
16+ credentials : {
17+ accessKeyId : process . env . AWS_ACCESS_KEY ,
18+ secretAccessKey : process . env . AWS_SECRET_KEY
19+ } ,
20+ region : process . env . AWS_REGION
21+ } ) ;
22+
1423export {
1524 default as createProject ,
1625 apiCreateProject
@@ -48,10 +57,18 @@ export async function updateProject(req, res) {
4857 } ) ;
4958 return ;
5059 }
60+ // only allow whitelisted fields so ownership/slug etc can't be overwritten
61+ const allowedFields = [ 'name' , 'files' , 'updatedAt' , 'visibility' ] ;
62+ const updateData = { } ;
63+ allowedFields . forEach ( ( field ) => {
64+ if ( req . body [ field ] !== undefined ) {
65+ updateData [ field ] = req . body [ field ] ;
66+ }
67+ } ) ;
5168 const updatedProject = await Project . findByIdAndUpdate (
5269 req . params . project_id ,
5370 {
54- $set : req . body
71+ $set : updateData
5572 } ,
5673 {
5774 new : true ,
@@ -158,7 +175,7 @@ export async function projectExists(projectId) {
158175
159176/**
160177 * @param {string } username
161- * @param {string } projectId - the database id or the slug or the project
178+ * @param {string } projectId
162179 * @return {Promise<boolean> }
163180 */
164181export async function projectForUserExists ( username , projectId ) {
@@ -189,13 +206,14 @@ export async function getProjectForUser(username, projectId) {
189206}
190207
191208/**
192- * Adds URLs referenced in <script> tags to the `files` array of the project
193- * so that they can be downloaded along with other remote files from S3.
194209 * @param {object } project
195- * @void - modifies the `project` parameter
196210 */
197211function bundleExternalLibs ( project ) {
198212 const indexHtml = project . files . find ( ( file ) => file . name === 'index.html' ) ;
213+ if ( ! indexHtml || ! indexHtml . content ) {
214+ return ;
215+ }
216+
199217 const { window } = new JSDOM ( indexHtml . content ) ;
200218 const scriptTags = window . document . getElementsByTagName ( 'script' ) ;
201219
@@ -205,6 +223,10 @@ function bundleExternalLibs(project) {
205223 const path = src . split ( '/' ) ;
206224 const filename = path [ path . length - 1 ] ;
207225
226+ if ( project . files . some ( ( f ) => f . name === filename && f . url === src ) ) {
227+ return ;
228+ }
229+
208230 project . files . push ( {
209231 name : filename ,
210232 url : src
@@ -216,38 +238,74 @@ function bundleExternalLibs(project) {
216238}
217239
218240/**
219- * Recursively adds a file and all of its children to the JSZip instance.
241+ * @param {string } url - S3 URL
242+ * @return {Promise<Readable> }
243+ */
244+ async function getStreamFromS3Url ( url ) {
245+ const urlObj = new URL ( url ) ;
246+ let bucket ;
247+ let key ;
248+
249+ if ( urlObj . hostname . includes ( 's3' ) ) {
250+ if ( urlObj . hostname . startsWith ( 's3' ) ) {
251+ const pathParts = urlObj . pathname . split ( '/' ) . filter ( Boolean ) ;
252+ [ bucket ] = pathParts ;
253+ key = pathParts . slice ( 1 ) . join ( '/' ) ;
254+ } else {
255+ [ bucket ] = urlObj . hostname . split ( '.' ) ;
256+ key = urlObj . pathname . substring ( 1 ) ;
257+ }
258+
259+ const command = new GetObjectCommand ( { Bucket : bucket , Key : key } ) ;
260+ const response = await s3Client . send ( command ) ;
261+ return response . Body ;
262+ }
263+
264+ const response = await axios . get ( url , {
265+ responseType : 'stream' ,
266+ timeout : 30000
267+ } ) ;
268+ return response . data ;
269+ }
270+
271+ /**
220272 * @param {object } file
221273 * @param {Array<object> } files
222274 * @param {JSZip } zip
223- * @return {Promise<void> } - modifies the `zip` parameter
275+ * @return {Promise<void> }
224276 */
225277async function addFileToZip ( file , files , zip ) {
226278 if ( file . fileType === 'folder' ) {
227279 const folderZip = file . name === 'root' ? zip : zip . folder ( file . name ) ;
228- await Promise . all (
229- file . children . map ( ( fileId ) => {
230- const childFile = files . find ( ( f ) => f . id === fileId ) ;
231- return addFileToZip ( childFile , files , folderZip ) ;
232- } )
233- ) ;
280+ await file . children . reduce ( async ( previousPromise , fileId ) => {
281+ await previousPromise ;
282+ const childFile = files . find ( ( f ) => f . id === fileId ) ;
283+ return addFileToZip ( childFile , files , folderZip ) ;
284+ } , Promise . resolve ( ) ) ;
234285 } else if ( file . url ) {
235286 try {
236- const res = await axios . get ( file . url , {
237- responseType : 'arraybuffer' ,
238- timeout : 30000 // 30 second timeout to prevent hanging requests
239- } ) ;
240- zip . file ( file . name , res . data ) ;
287+ if ( file . url . includes ( 's3' ) && file . url . includes ( 'amazonaws.com' ) ) {
288+ const stream = await getStreamFromS3Url ( file . url ) ;
289+ zip . file ( file . name , stream , { binary : true } ) ;
290+ } else {
291+ const response = await axios . get ( file . url , {
292+ responseType : 'stream' ,
293+ timeout : 30000
294+ } ) ;
295+ zip . file ( file . name , response . data , { binary : true } ) ;
296+ }
241297 } catch ( e ) {
242298 console . warn ( `Failed to fetch file from ${ file . url } :` , e . message ) ;
243- zip . file ( file . name , new ArrayBuffer ( 0 ) ) ;
299+ zip . file ( file . name , Buffer . alloc ( 0 ) ) ;
244300 }
245301 } else {
246302 zip . file ( file . name , file . content ) ;
247303 }
248304}
249305
250306async function buildZip ( project , req , res ) {
307+ let keepaliveInterval ;
308+
251309 try {
252310 const zip = new JSZip ( ) ;
253311 const currentTime = format ( new Date ( ) , 'yyyy_MM_dd_HH_mm_ss' ) ;
@@ -258,30 +316,77 @@ async function buildZip(project, req, res) {
258316 const { files } = project ;
259317 const root = files . find ( ( file ) => file . name === 'root' ) ;
260318
319+ if ( ! root ) {
320+ throw new Error ( 'Project has no root folder' ) ;
321+ }
322+
261323 bundleExternalLibs ( project ) ;
324+
325+ res . writeHead ( 200 , {
326+ 'Content-Type' : 'application/zip' ,
327+ 'Content-disposition' : `attachment; filename=${ zipFileName } ` ,
328+ 'Transfer-Encoding' : 'chunked'
329+ } ) ;
330+
331+ let keepaliveCounter = 0 ;
332+ keepaliveInterval = setInterval ( ( ) => {
333+ if ( ! res . writableEnded ) {
334+ res . write ( Buffer . alloc ( 0 ) ) ;
335+ keepaliveCounter ++ ;
336+ if ( keepaliveCounter % 10 === 0 ) {
337+ console . log (
338+ `Keepalive: Building ZIP file list (${ keepaliveCounter } s elapsed)...`
339+ ) ;
340+ }
341+ }
342+ } , 1000 ) ;
343+
262344 await addFileToZip ( root , files , zip ) ;
263345
264- const base64 = await zip . generateAsync ( { type : 'base64' } ) ;
265- const buff = Buffer . from ( base64 , 'base64' ) ;
346+ clearInterval ( keepaliveInterval ) ;
347+ keepaliveInterval = null ;
266348
267- // nityam Check if response was already sent (e.g., client disconnected)
268- if ( res . headersSent ) {
269- return ;
270- }
349+ const zipStream = zip . generateNodeStream ( {
350+ type : 'nodebuffer' ,
351+ streamFiles : true ,
352+ compression : 'DEFLATE' ,
353+ compressionOptions : { level : 6 }
354+ } ) ;
271355
272- res . writeHead ( 200 , {
273- 'Content-Type' : 'application/zip' ,
274- 'Content-disposition' : `attachment; filename=${ zipFileName } `
356+ zipStream . pipe ( res ) ;
357+
358+ zipStream . on ( 'error' , ( err ) => {
359+ console . error ( 'Error streaming zip file:' , err ) ;
360+ if ( ! res . headersSent ) {
361+ res . status ( 500 ) . json ( {
362+ success : false ,
363+ message : 'Failed to generate zip file. Please try again.'
364+ } ) ;
365+ } else {
366+ res . end ( ) ;
367+ }
368+ } ) ;
369+
370+ await new Promise ( ( resolve , reject ) => {
371+ zipStream . on ( 'end' , resolve ) ;
372+ zipStream . on ( 'error' , reject ) ;
373+ res . on ( 'error' , reject ) ;
374+ res . on ( 'close' , ( ) => reject ( new Error ( 'Client disconnected' ) ) ) ;
275375 } ) ;
276- res . end ( buff ) ;
277376 } catch ( err ) {
278377 console . error ( 'Error building zip file:' , err ) ;
279- // Only send error if response hasn't been sent yet
378+
379+ if ( keepaliveInterval ) {
380+ clearInterval ( keepaliveInterval ) ;
381+ }
382+
280383 if ( ! res . headersSent ) {
281384 res . status ( 500 ) . json ( {
282385 success : false ,
283386 message : 'Failed to generate zip file. Please try again.'
284387 } ) ;
388+ } else {
389+ res . end ( ) ;
285390 }
286391 }
287392}
@@ -293,11 +398,9 @@ export async function downloadProjectAsZip(req, res) {
293398 res . status ( 404 ) . send ( { message : 'Project with that id does not exist' } ) ;
294399 return ;
295400 }
296- // Await buildZip to ensure it completes before the function returns
297401 await buildZip ( project , req , res ) ;
298402 } catch ( err ) {
299403 console . error ( 'Error in downloadProjectAsZip:' , err ) ;
300- // Only send error if response hasn't been sent yet
301404 if ( ! res . headersSent ) {
302405 res . status ( 500 ) . json ( {
303406 success : false ,
0 commit comments