Skip to content

Commit d5635df

Browse files
authored
Merge branch 'develop' into test-files-delete
2 parents 15491d5 + 7cd2cae commit d5635df

5 files changed

Lines changed: 293 additions & 42 deletions

File tree

client/modules/IDE/actions/project.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,12 +420,12 @@ export function changeVisibility(projectId, projectName, visibility, t) {
420420
.patch('/project/visibility', { projectId, visibility })
421421
.then((response) => {
422422
if (response.status === 200) {
423-
const { visibility: newVisibility, updatedAt } = response.data;
423+
const { visibility: newVisibility, updatedAt, id } = response.data;
424424

425425
dispatch({
426426
type: ActionTypes.CHANGE_VISIBILITY,
427427
payload: {
428-
id: response.data.id,
428+
id,
429429
visibility: newVisibility
430430
}
431431
});

client/modules/IDE/reducers/projects.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ const sketches = (state = [], action) => {
77
case ActionTypes.DELETE_PROJECT:
88
return state.projects.filter((sketch) => sketch.id !== action.id);
99
case ActionTypes.CHANGE_VISIBILITY: {
10-
return state.map((sketch) => {
11-
if (sketch.id === action.payload.id) {
12-
return { ...sketch, visibility: action.payload.visibility };
13-
}
14-
return sketch;
15-
});
10+
const updatedProjects = state.projects.map((sketch) =>
11+
sketch.id === action.payload.id
12+
? { ...sketch, visibility: action.payload.visibility }
13+
: sketch
14+
);
15+
16+
return {
17+
...state,
18+
projects: updatedProjects
19+
};
1620
}
21+
1722
case ActionTypes.RENAME_PROJECT: {
1823
return state.map((sketch) => {
1924
if (sketch.id === action.payload.id) {

server/controllers/project.controller.js

Lines changed: 135 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@ import mime from 'mime';
66
import isAfter from 'date-fns/isAfter';
77
import axios from 'axios';
88
import slugify from 'slugify';
9+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
910
import Project from '../models/project';
1011
import { User } from '../models/user';
1112
import { resolvePathToFile } from '../utils/filePath';
1213
import { 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+
1423
export {
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
*/
164181
export 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
*/
197211
function 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
*/
225277
async 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

250306
async 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

Comments
 (0)