Skip to content

Commit bc552e0

Browse files
authored
Merge branch 'develop' into feat-nepali-language
2 parents 127a0cd + 585c3c0 commit bc552e0

6 files changed

Lines changed: 297 additions & 8 deletions

File tree

client/modules/IDE/actions/uploader.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,23 @@ export function dropzoneCompleteCallback(file) {
8282
content: file.content
8383
};
8484
dispatch(handleCreateFile(formParams, false));
85+
} else if (file.status === 'error' || file.xhr.status >= 400) {
86+
let uploadFileErrorMessage = 'Uploading file to AWS failed.';
87+
if (file.xhr?.response) {
88+
const parser = new DOMParser();
89+
const xmlDoc = parser.parseFromString(file.xhr.response, 'text/xml');
90+
const message = xmlDoc.getElementsByTagName('Message')[0]?.textContent;
91+
const code = xmlDoc.getElementsByTagName('Code')[0]?.textContent;
92+
uploadFileErrorMessage = `${code}: ${message}`;
93+
}
94+
file.previewElement.classList.add('dz-error');
95+
file.previewElement.classList.remove('dz-success');
96+
const dzErrorMessageElement = file.previewElement?.querySelector(
97+
'[data-dz-errormessage]'
98+
);
99+
if (dzErrorMessageElement) {
100+
dzErrorMessageElement.textContent = uploadFileErrorMessage;
101+
}
85102
}
86103
};
87104
}

client/modules/IDE/components/FileUploader.jsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,36 @@ const StyledUploader = styled.div`
2323
.dz-preview.dz-image-preview {
2424
background-color: transparent;
2525
}
26+
.dz-image img {
27+
width: 100%;
28+
height: auto;
29+
}
30+
.dz-details .dz-filename:hover {
31+
overflow: hidden;
32+
}
33+
.dz-error-message span {
34+
width: 100%;
35+
height: auto;
36+
overflow: hidden;
37+
display: block;
38+
}
39+
.dz-error-mark:hover {
40+
cursor: pointer !important;
41+
}
2642
`;
2743

2844
function FileUploader() {
2945
const { t } = useTranslation();
3046
const dispatch = useDispatch();
3147
const userId = useSelector((state) => state.user.id);
32-
48+
const deleteUploadErrorFiles = (uploader, file) => {
49+
if (file.status === 'error') {
50+
file.previewElement.addEventListener('click', (e) => {
51+
e.stopPropagation();
52+
uploader.removeFile(file);
53+
});
54+
}
55+
};
3356
useEffect(() => {
3457
const uploader = new Dropzone('div#uploader', {
3558
url: s3BucketHttps,
@@ -52,6 +75,7 @@ function FileUploader() {
5275
});
5376
uploader.on('complete', (file) => {
5477
dispatch(dropzoneCompleteCallback(file));
78+
deleteUploadErrorFiles(uploader, file);
5579
});
5680
return () => {
5781
uploader.destroy();

contributor_docs/installation.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ _Note_: The installation steps assume you are using a Unix-like shell. If you ar
3737
7. Install MongoDB and make sure it is running
3838
* For Mac OSX with [homebrew](http://brew.sh/): `brew tap mongodb/brew` then `brew install mongodb-community` and finally start the server with `brew services start mongodb-community` or you can visit the installation guide here [Installation Guide For MacOS](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-os-x/)
3939
* For Windows and Linux: [MongoDB Installation](https://docs.mongodb.com/manual/installation/)
40+
* If you have trouble setting up MongoDB locally, an alternative is to use [MongoDB Atlas](https://cloud.mongodb.com/) to get a connection string that you can use as your `MONGO_URL` in the `.env` file. To get your connection string:
41+
- Navigate to [mongodb.com](https://www.mongodb.com/) and sign up or log in.
42+
- Create a new project. Give it any name, and either add a key-value pair or skip that step.
43+
- Create a cluster by choosing the free tier. Give your cluster a name, choose a region, and keep the provider as AWS.
44+
- Set a username and password for your database-user, these will be part of your connection string.
45+
- Choose **Node.js** as the driver for your connection method. You will see a connection string, with or without the password filled in.
46+
- Copy the string and use it as your `MONGO_URL` in the `.env` file.
4047
8. `$ cp .env.example .env`
4148
9. (Optional) Update `.env` with necessary keys to enable certain app behaviors, i.e. add Github ID and Github Secret if you want to be able to log in with Github.
4249
* See the [GitHub API Configuration](#github-api-configuration) section for information on how to authenticate with Github.

server/config/passport.js

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ const getVerifiedEmails = (githubEmails) =>
124124
const getPrimaryEmail = (githubEmails) =>
125125
(lodash.find(githubEmails, { primary: true }) || {}).value;
126126

127+
/**
128+
* Get primary email from Google OAuth profile.
129+
* Returns the first email if available, or null if emails array is missing/empty.
130+
*/
131+
const getGooglePrimaryEmail = (googleEmails) => {
132+
if (!Array.isArray(googleEmails) || googleEmails.length === 0) {
133+
return null;
134+
}
135+
const primaryEmail = googleEmails[0]?.value?.trim();
136+
return primaryEmail || null;
137+
};
138+
127139
/**
128140
* Sign in with GitHub.
129141
*/
@@ -240,8 +252,18 @@ passport.use(
240252
},
241253
async (req, accessToken, refreshToken, profile, done) => {
242254
try {
255+
// Validate that emails array exists and has at least one element
256+
const primaryEmail = getGooglePrimaryEmail(profile._json?.emails);
257+
if (!primaryEmail) {
258+
return done(null, false, {
259+
msg:
260+
'Unable to retrieve email from Google account. ' +
261+
'Please ensure your Google account has an email address and try again.'
262+
});
263+
}
264+
243265
const existingUser = await User.findOne({
244-
google: profile._json.emails[0].value
266+
google: primaryEmail
245267
}).exec();
246268

247269
if (existingUser) {
@@ -258,18 +280,16 @@ passport.use(
258280
return done(null, existingUser);
259281
}
260282

261-
const primaryEmail = profile._json.emails[0].value;
262-
263283
if (req.user) {
264284
if (!req.user.google) {
265-
req.user.google = profile._json.emails[0].value;
285+
req.user.google = primaryEmail;
266286
req.user.tokens.push({ kind: 'google', accessToken });
267287
req.user.verified = User.EmailConfirmation().Verified;
268288
}
269289
await req.user.save();
270290
return done(null, req.user);
271291
}
272-
let username = profile._json.emails[0].value.split('@')[0];
292+
let username = primaryEmail.split('@')[0];
273293
const existingEmailUser = await User.findByEmail(primaryEmail);
274294
const existingUsernameUser = await User.findByUsername(username, {
275295
caseInsensitive: true
@@ -285,7 +305,7 @@ passport.use(
285305
return done(null, false, { msg: accountSuspensionMessage });
286306
}
287307
existingEmailUser.email = existingEmailUser.email || primaryEmail;
288-
existingEmailUser.google = profile._json.emails[0].value;
308+
existingEmailUser.google = primaryEmail;
289309
existingEmailUser.username = existingEmailUser.username || username;
290310
existingEmailUser.tokens.push({
291311
kind: 'google',
@@ -301,7 +321,7 @@ passport.use(
301321

302322
const user = new User();
303323
user.email = primaryEmail;
304-
user.google = profile._json.emails[0].value;
324+
user.google = primaryEmail;
305325
user.username = username;
306326
user.tokens.push({ kind: 'google', accessToken });
307327
user.name = profile._json.displayName;

server/controllers/project.controller.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ export async function getProjectAsset(req, res) {
131131
.send({ message: 'Project with that id does not exist' });
132132
}
133133

134+
// Check visibility and ownership for private projects
135+
if (
136+
project.visibility === 'Private' &&
137+
(!req.user || !project.user._id.equals(req.user._id))
138+
) {
139+
return res.status(403).send({ message: 'Project is private' });
140+
}
141+
134142
const filePath = req.params[0];
135143
const resolvedFile = resolvePathToFile(filePath, project.files);
136144
if (!resolvedFile) {
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import { Request, Response } from 'jest-express';
5+
import axios from 'axios';
6+
import mime from 'mime';
7+
import Project from '../../../models/project';
8+
import { getProjectAsset } from '../../project.controller';
9+
import { resolvePathToFile } from '../../../utils/filePath';
10+
11+
jest.mock('../../../models/project');
12+
jest.mock('../../../utils/filePath');
13+
jest.mock('mime');
14+
jest.mock('axios');
15+
16+
describe('project.controller', () => {
17+
describe('getProjectAsset()', () => {
18+
let request;
19+
let response;
20+
21+
beforeEach(() => {
22+
request = new Request();
23+
response = new Response();
24+
response.send = jest.fn();
25+
response.status = jest.fn().mockReturnThis();
26+
response.set = jest.fn().mockReturnThis();
27+
request.setParams({ project_id: 'project-123', 0: 'image.png' });
28+
Project.findOne = jest.fn().mockReturnValue({
29+
populate: jest.fn().mockReturnThis(),
30+
exec: jest.fn()
31+
});
32+
jest.clearAllMocks();
33+
});
34+
35+
afterEach(() => {
36+
request.resetMocked();
37+
response.resetMocked();
38+
});
39+
40+
it('returns 404 if project does not exist', async () => {
41+
Project.findOne().populate().exec.mockResolvedValue(null);
42+
43+
await getProjectAsset(request, response);
44+
45+
expect(response.status).toHaveBeenCalledWith(404);
46+
expect(response.send).toHaveBeenCalledWith({
47+
message: 'Project with that id does not exist'
48+
});
49+
});
50+
51+
it('returns 403 if private project is accessed by unauthenticated user', async () => {
52+
const project = {
53+
_id: 'project-123',
54+
visibility: 'Private',
55+
user: { _id: { equals: jest.fn() } },
56+
files: []
57+
};
58+
request.user = undefined;
59+
60+
Project.findOne().populate().exec.mockResolvedValue(project);
61+
62+
await getProjectAsset(request, response);
63+
64+
expect(response.status).toHaveBeenCalledWith(403);
65+
expect(response.send).toHaveBeenCalledWith({
66+
message: 'Project is private'
67+
});
68+
});
69+
70+
it('returns 403 if private project is accessed by non-owner', async () => {
71+
const ownerId = { equals: jest.fn().mockReturnValue(false) };
72+
const project = {
73+
_id: 'project-123',
74+
visibility: 'Private',
75+
user: { _id: ownerId },
76+
files: []
77+
};
78+
request.user = { _id: 'other-user-id' };
79+
80+
Project.findOne().populate().exec.mockResolvedValue(project);
81+
82+
await getProjectAsset(request, response);
83+
84+
expect(response.status).toHaveBeenCalledWith(403);
85+
expect(response.send).toHaveBeenCalledWith({
86+
message: 'Project is private'
87+
});
88+
expect(ownerId.equals).toHaveBeenCalledWith('other-user-id');
89+
});
90+
91+
it('allows owner to access private project assets', async () => {
92+
const ownerId = 'owner-123';
93+
const ownerIdObj = { equals: jest.fn().mockReturnValue(true) };
94+
const project = {
95+
_id: 'project-123',
96+
visibility: 'Private',
97+
user: { _id: ownerIdObj },
98+
files: []
99+
};
100+
const resolvedFile = {
101+
name: 'image.png',
102+
content: Buffer.from('image content')
103+
};
104+
105+
request.user = { _id: ownerId };
106+
Project.findOne().populate().exec.mockResolvedValue(project);
107+
resolvePathToFile.mockReturnValue(resolvedFile);
108+
mime.getType.mockReturnValue('image/png');
109+
110+
await getProjectAsset(request, response);
111+
112+
expect(ownerIdObj.equals).toHaveBeenCalledWith(ownerId);
113+
expect(response.status).not.toHaveBeenCalledWith(403);
114+
expect(response.set).toHaveBeenCalledWith('Content-Type', 'image/png');
115+
expect(response.send).toHaveBeenCalledWith(resolvedFile.content);
116+
});
117+
118+
it('allows anyone to access public project assets', async () => {
119+
const project = {
120+
_id: 'project-123',
121+
visibility: 'Public',
122+
user: { _id: { equals: jest.fn() } },
123+
files: []
124+
};
125+
const resolvedFile = {
126+
name: 'image.png',
127+
content: Buffer.from('image content')
128+
};
129+
130+
request.user = undefined; // unauthenticated
131+
Project.findOne().populate().exec.mockResolvedValue(project);
132+
resolvePathToFile.mockReturnValue(resolvedFile);
133+
mime.getType.mockReturnValue('image/png');
134+
135+
await getProjectAsset(request, response);
136+
137+
expect(response.status).not.toHaveBeenCalledWith(403);
138+
expect(response.set).toHaveBeenCalledWith('Content-Type', 'image/png');
139+
expect(response.send).toHaveBeenCalledWith(resolvedFile.content);
140+
});
141+
142+
it('returns 404 if asset does not exist', async () => {
143+
const project = {
144+
_id: 'project-123',
145+
visibility: 'Public',
146+
user: { _id: { equals: jest.fn() } },
147+
files: []
148+
};
149+
150+
Project.findOne().populate().exec.mockResolvedValue(project);
151+
resolvePathToFile.mockReturnValue(null);
152+
153+
await getProjectAsset(request, response);
154+
155+
expect(response.status).toHaveBeenCalledWith(404);
156+
expect(response.send).toHaveBeenCalledWith({
157+
message: 'Asset does not exist'
158+
});
159+
});
160+
161+
it('fetches and serves asset from URL when resolvedFile has url', async () => {
162+
const project = {
163+
_id: 'project-123',
164+
visibility: 'Public',
165+
user: { _id: { equals: jest.fn() } },
166+
files: []
167+
};
168+
const resolvedFile = {
169+
name: 'image.png',
170+
url: 'https://example.com/image.png'
171+
};
172+
const imageData = Buffer.from('image data');
173+
174+
Project.findOne().populate().exec.mockResolvedValue(project);
175+
resolvePathToFile.mockReturnValue(resolvedFile);
176+
mime.getType.mockReturnValue('image/png');
177+
axios.get.mockResolvedValue({ data: imageData });
178+
179+
await getProjectAsset(request, response);
180+
181+
expect(axios.get).toHaveBeenCalledWith(resolvedFile.url, {
182+
responseType: 'arraybuffer'
183+
});
184+
expect(response.set).toHaveBeenCalledWith('Content-Type', 'image/png');
185+
expect(response.send).toHaveBeenCalledWith(imageData);
186+
});
187+
188+
it('returns 404 if fetching asset URL fails', async () => {
189+
const project = {
190+
_id: 'project-123',
191+
visibility: 'Public',
192+
user: { _id: { equals: jest.fn() } },
193+
files: []
194+
};
195+
const resolvedFile = {
196+
name: 'image.png',
197+
url: 'https://example.com/image.png'
198+
};
199+
200+
Project.findOne().populate().exec.mockResolvedValue(project);
201+
resolvePathToFile.mockReturnValue(resolvedFile);
202+
mime.getType.mockReturnValue('image/png');
203+
axios.get.mockRejectedValue(new Error('Network error'));
204+
205+
await getProjectAsset(request, response);
206+
207+
expect(response.status).toHaveBeenCalledWith(404);
208+
expect(response.send).toHaveBeenCalledWith({
209+
message: 'Asset does not exist'
210+
});
211+
});
212+
});
213+
});

0 commit comments

Comments
 (0)