Public API: Create new project (fixes #1095) (#1106)

* Converts import script to use public API endpoints

The endpoints don't exist yet, but this is a good way to see how
the implementation of the data structures differ.

* Exposes public API endpoint to fetch user's sketches

* Implements public API delete endpoint

* Adds helper to create custom ApplicationError classes

* Adds create project endpoint that understand API's data structure

This transforms the nested tree of file data into a mongoose
Project model

* Returns '201 Created' to match API spec

* Removes 'CustomError' variable assignment as it shows up in test output

* transformFiles will return file validation errors

* Tests API project controller

* Tests toModel()

* Creates default files if no root-level .html file is provided

* Do not auto-generate a slug if it is provided

Fixes a bug where the slug was auto-generated using the sketch name,
even if a slug property had been provided.

* Validates uniqueness of slugs for projects created by the public API

* Adds tests for slug uniqueness

* Configures node's Promise implementation for mongoose (fixes warnings)

* Moves createProject tests to match controller location

* Adds support for code to ApplicationErrors

* deleteProject controller tests

* getProjectsForUser controller tests

- implements tests
- update apiKey tests to use new User mocks

* Ensure error objects have consistent property names

`message` is used as a high-level description of the errors
`detail` is optional and has an plain language explanation of the
individual errors
`errors` is an array of each individual problem from `detail` in a
machine-readable format

* Assert environment variables are provided at script start

* Version public API

* Expect "files" property to always be provided

* Fixes linting error

* Converts import script to use public API endpoints

The endpoints don't exist yet, but this is a good way to see how
the implementation of the data structures differ.

* Exposes public API endpoint to fetch user's sketches

* Implements public API delete endpoint

* Adds helper to create custom ApplicationError classes

* Adds create project endpoint that understand API's data structure

This transforms the nested tree of file data into a mongoose
Project model

* Returns '201 Created' to match API spec

* Removes 'CustomError' variable assignment as it shows up in test output

* transformFiles will return file validation errors

* Tests API project controller

* Tests toModel()

* Creates default files if no root-level .html file is provided

* Do not auto-generate a slug if it is provided

Fixes a bug where the slug was auto-generated using the sketch name,
even if a slug property had been provided.

* Validates uniqueness of slugs for projects created by the public API

* Adds tests for slug uniqueness

* Configures node's Promise implementation for mongoose (fixes warnings)

* Moves createProject tests to match controller location

* deleteProject controller tests

* Adds support for code to ApplicationErrors

* getProjectsForUser controller tests

- implements tests
- update apiKey tests to use new User mocks

* Ensure error objects have consistent property names

`message` is used as a high-level description of the errors
`detail` is optional and has an plain language explanation of the
individual errors
`errors` is an array of each individual problem from `detail` in a
machine-readable format

* Assert environment variables are provided at script start

* Version public API

* Expect "files" property to always be provided

* Fixes linting error

* Checks that authenticated user has permission to create under this namespace

Previously, the project was always created under the authenticated user's
namespace, but this not obvious behaviour.
This commit is contained in:
Andrew Nicolaou 2019-08-30 20:26:57 +02:00 committed by Cassie Tarakajian
parent 443232380c
commit d44a058fd8
24 changed files with 1905 additions and 552 deletions

View File

@ -1,5 +1,8 @@
import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import '@babel/polyfill'
import mongoose from 'mongoose'
mongoose.Promise = global.Promise;
configure({ adapter: new Adapter() })

View File

@ -0,0 +1,5 @@
export const getObjectKey = jest.mock();
export const deleteObjectsFromS3 = jest.fn();
export const signS3 = jest.fn();
export const copyObjectInS3 = jest.fn();
export const listObjectsInS3ForUser = jest.fn();

View File

@ -1,155 +0,0 @@
/**
* @jest-environment node
*/
import { Response } from 'jest-express';
import { createMock } from '../../models/project';
import createProject from '../project.controller/createProject';
jest.mock('../../models/project');
describe('project.controller', () => {
describe('createProject()', () => {
let ProjectMock;
beforeEach(() => {
ProjectMock = createMock();
});
afterEach(() => {
ProjectMock.restore();
});
it('fails if create fails', (done) => {
const error = new Error('An error');
ProjectMock
.expects('create')
.rejects(error);
const request = { user: {} };
const response = new Response();
const promise = createProject(request, response);
function expectations() {
expect(response.json).toHaveBeenCalledWith({ success: false });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('extracts parameters from request body', (done) => {
const request = {
user: { _id: 'abc123' },
body: {
name: 'Wriggly worm',
files: [{ name: 'file.js', content: 'var hello = true;' }]
}
};
const response = new Response();
ProjectMock
.expects('create')
.withArgs({
user: 'abc123',
name: 'Wriggly worm',
files: [{ name: 'file.js', content: 'var hello = true;' }]
})
.resolves();
const promise = createProject(request, response);
function expectations() {
expect(response.json).toHaveBeenCalled();
done();
}
promise.then(expectations, expectations).catch(expectations);
});
// TODO: This should be extracted to a new model object
// so the controllers just have to call a single
// method for this operation
it('populates referenced user on project creation', (done) => {
const request = { user: { _id: 'abc123' } };
const response = new Response();
const result = {
_id: 'abc123',
id: 'abc123',
name: 'Project name',
serveSecure: false,
files: []
};
const resultWithUser = {
...result,
user: {}
};
ProjectMock
.expects('create')
.withArgs({ user: 'abc123' })
.resolves(result);
ProjectMock
.expects('populate')
.withArgs(result)
.yields(null, resultWithUser)
.resolves(resultWithUser);
const promise = createProject(request, response);
function expectations() {
const doc = response.json.mock.calls[0][0];
expect(response.json).toHaveBeenCalled();
expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser);
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('fails if referenced user population fails', (done) => {
const request = { user: { _id: 'abc123' } };
const response = new Response();
const result = {
_id: 'abc123',
id: 'abc123',
name: 'Project name',
serveSecure: false,
files: []
};
const error = new Error('An error');
ProjectMock
.expects('create')
.resolves(result);
ProjectMock
.expects('populate')
.yields(error)
.resolves(error);
const promise = createProject(request, response);
function expectations() {
expect(response.json).toHaveBeenCalledWith({ success: false });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
});
});

View File

@ -2,7 +2,6 @@ import archiver from 'archiver';
import format from 'date-fns/format';
import isUrl from 'is-url';
import jsdom, { serializeDocument } from 'jsdom';
import isBefore from 'date-fns/is_before';
import isAfter from 'date-fns/is_after';
import request from 'request';
import slugify from 'slugify';
@ -10,9 +9,10 @@ import Project from '../models/project';
import User from '../models/user';
import { resolvePathToFile } from '../utils/filePath';
import generateFileSystemSafeName from '../utils/generateFileSystemSafeName';
import { deleteObjectsFromS3, getObjectKey } from './aws.controller';
export { default as createProject } from './project.controller/createProject';
export { default as createProject, apiCreateProject } from './project.controller/createProject';
export { default as deleteProject } from './project.controller/deleteProject';
export { default as getProjectsForUser, apiGetProjectsForUser } from './project.controller/getProjectsForUser';
export function updateProject(req, res) {
Project.findById(req.params.project_id, (findProjectErr, project) => {
@ -84,37 +84,6 @@ export function getProject(req, res) {
});
}
function deleteFilesFromS3(files) {
deleteObjectsFromS3(files.filter((file) => {
if (file.url) {
if (!process.env.S3_DATE || (
process.env.S3_DATE &&
isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) {
return true;
}
}
return false;
})
.map(file => getObjectKey(file.url)));
}
export function deleteProject(req, res) {
Project.findById(req.params.project_id, (findProjectErr, project) => {
if (!project.user.equals(req.user._id)) {
res.status(403).json({ success: false, message: 'Session does not match owner of project.' });
return;
}
deleteFilesFromS3(project.files);
Project.remove({ _id: req.params.project_id }, (removeProjectError) => {
if (removeProjectError) {
res.status(404).send({ message: 'Project with that id does not exist' });
return;
}
res.json({ success: true });
});
});
}
export function getProjectsForUserId(userId) {
return new Promise((resolve, reject) => {
Project.find({ user: userId })
@ -157,10 +126,6 @@ export function getProjectAsset(req, res) {
});
}
export function getProjectsForUserName(username) {
}
export function getProjects(req, res) {
if (req.user) {
getProjectsForUserId(req.user._id)
@ -173,24 +138,6 @@ export function getProjects(req, res) {
}
}
export function getProjectsForUser(req, res) {
if (req.params.username) {
User.findOne({ username: req.params.username }, (err, user) => {
if (!user) {
res.status(404).json({ message: 'User with that username does not exist.' });
return;
}
Project.find({ user: user._id })
.sort('-createdAt')
.select('name files id createdAt updatedAt')
.exec((innerErr, projects) => res.json(projects));
});
} else {
// could just move this to client side
res.json([]);
}
}
export function projectExists(projectId, callback) {
Project.findById(projectId, (err, project) => (
project ? callback(true) : callback(false)

View File

@ -0,0 +1,391 @@
/**
* @jest-environment node
*/
import { Response } from 'jest-express';
import Project, { createMock, createInstanceMock } from '../../../models/project';
import createProject, { apiCreateProject } from '../createProject';
jest.mock('../../../models/project');
describe('project.controller', () => {
describe('createProject()', () => {
let ProjectMock;
beforeEach(() => {
ProjectMock = createMock();
});
afterEach(() => {
ProjectMock.restore();
});
it('fails if create fails', (done) => {
const error = new Error('An error');
ProjectMock
.expects('create')
.rejects(error);
const request = { user: {} };
const response = new Response();
const promise = createProject(request, response);
function expectations() {
expect(response.json).toHaveBeenCalledWith({ success: false });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('extracts parameters from request body', (done) => {
const request = {
user: { _id: 'abc123' },
body: {
name: 'Wriggly worm',
files: [{ name: 'file.js', content: 'var hello = true;' }]
}
};
const response = new Response();
ProjectMock
.expects('create')
.withArgs({
user: 'abc123',
name: 'Wriggly worm',
files: [{ name: 'file.js', content: 'var hello = true;' }]
})
.resolves();
const promise = createProject(request, response);
function expectations() {
expect(response.json).toHaveBeenCalled();
done();
}
promise.then(expectations, expectations).catch(expectations);
});
// TODO: This should be extracted to a new model object
// so the controllers just have to call a single
// method for this operation
it('populates referenced user on project creation', (done) => {
const request = { user: { _id: 'abc123' } };
const response = new Response();
const result = {
_id: 'abc123',
id: 'abc123',
name: 'Project name',
serveSecure: false,
files: []
};
const resultWithUser = {
...result,
user: {}
};
ProjectMock
.expects('create')
.withArgs({ user: 'abc123' })
.resolves(result);
ProjectMock
.expects('populate')
.withArgs(result)
.yields(null, resultWithUser)
.resolves(resultWithUser);
const promise = createProject(request, response);
function expectations() {
const doc = response.json.mock.calls[0][0];
expect(response.json).toHaveBeenCalled();
expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser);
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('fails if referenced user population fails', (done) => {
const request = { user: { _id: 'abc123' } };
const response = new Response();
const result = {
_id: 'abc123',
id: 'abc123',
name: 'Project name',
serveSecure: false,
files: []
};
const error = new Error('An error');
ProjectMock
.expects('create')
.resolves(result);
ProjectMock
.expects('populate')
.yields(error)
.resolves(error);
const promise = createProject(request, response);
function expectations() {
expect(response.json).toHaveBeenCalledWith({ success: false });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
});
describe('apiCreateProject()', () => {
let ProjectMock;
let ProjectInstanceMock;
beforeEach(() => {
ProjectMock = createMock();
ProjectInstanceMock = createInstanceMock();
});
afterEach(() => {
ProjectMock.restore();
ProjectInstanceMock.restore();
});
it('returns 201 with id of created sketch', (done) => {
const request = {
user: { _id: 'abc123', username: 'alice' },
params: { username: 'alice' },
body: {
name: 'My sketch',
files: {}
}
};
const response = new Response();
const result = {
_id: 'abc123',
id: 'abc123',
name: 'Project name',
serveSecure: false,
files: []
};
ProjectInstanceMock.expects('isSlugUnique')
.resolves({ isUnique: true, conflictingIds: [] });
ProjectInstanceMock.expects('save')
.resolves(new Project(result));
const promise = apiCreateProject(request, response);
function expectations() {
const doc = response.json.mock.calls[0][0];
expect(response.status).toHaveBeenCalledWith(201);
expect(response.json).toHaveBeenCalled();
expect(JSON.parse(JSON.stringify(doc))).toMatchObject({
id: 'abc123'
});
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('fails if slug is not unique', (done) => {
const request = {
user: { _id: 'abc123', username: 'alice' },
params: { username: 'alice' },
body: {
name: 'My sketch',
slug: 'a-slug',
files: {}
}
};
const response = new Response();
const result = {
_id: 'abc123',
id: 'abc123',
name: 'Project name',
// slug: 'a-slug',
serveSecure: false,
files: []
};
ProjectInstanceMock.expects('isSlugUnique')
.resolves({ isUnique: false, conflictingIds: ['cde123'] });
ProjectInstanceMock.expects('save')
.resolves(new Project(result));
const promise = apiCreateProject(request, response);
function expectations() {
const doc = response.json.mock.calls[0][0];
expect(response.status).toHaveBeenCalledWith(409);
expect(response.json).toHaveBeenCalled();
expect(JSON.parse(JSON.stringify(doc))).toMatchObject({
message: 'Sketch Validation Failed',
detail: 'Slug "a-slug" is not unique. Check cde123'
});
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('fails if user does not have permission', (done) => {
const request = {
user: { _id: 'abc123', username: 'alice' },
params: {
username: 'dana',
},
body: {
name: 'My sketch',
slug: 'a-slug',
files: {}
}
};
const response = new Response();
const result = {
_id: 'abc123',
id: 'abc123',
name: 'Project name',
serveSecure: false,
files: []
};
ProjectInstanceMock.expects('isSlugUnique')
.resolves({ isUnique: true, conflictingIds: [] });
ProjectInstanceMock.expects('save')
.resolves(new Project(result));
const promise = apiCreateProject(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(401);
expect(response.json).toHaveBeenCalled();
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('returns validation errors on files input', (done) => {
const request = {
user: { username: 'alice' },
params: { username: 'alice' },
body: {
name: 'My sketch',
files: {
'index.html': {
// missing content or url
}
}
}
};
const response = new Response();
const promise = apiCreateProject(request, response);
function expectations() {
const doc = response.json.mock.calls[0][0];
const responseBody = JSON.parse(JSON.stringify(doc));
expect(response.status).toHaveBeenCalledWith(422);
expect(responseBody.message).toBe('File Validation Failed');
expect(responseBody.detail).not.toBeNull();
expect(responseBody.errors.length).toBe(1);
expect(responseBody.errors).toEqual([
{ name: 'index.html', message: 'missing \'url\' or \'content\'' }
]);
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('rejects file parameters not in object format', (done) => {
const request = {
user: { _id: 'abc123', username: 'alice' },
params: { username: 'alice' },
body: {
name: 'Wriggly worm',
files: [{ name: 'file.js', content: 'var hello = true;' }]
}
};
const response = new Response();
const promise = apiCreateProject(request, response);
function expectations() {
const doc = response.json.mock.calls[0][0];
const responseBody = JSON.parse(JSON.stringify(doc));
expect(response.status).toHaveBeenCalledWith(422);
expect(responseBody.message).toBe('File Validation Failed');
expect(responseBody.detail).toBe('\'files\' must be an object');
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('rejects if files object in not provided', (done) => {
const request = {
user: { _id: 'abc123', username: 'alice' },
params: { username: 'alice' },
body: {
name: 'Wriggly worm',
// files: {} is missing
}
};
const response = new Response();
const promise = apiCreateProject(request, response);
function expectations() {
const doc = response.json.mock.calls[0][0];
const responseBody = JSON.parse(JSON.stringify(doc));
expect(response.status).toHaveBeenCalledWith(422);
expect(responseBody.message).toBe('File Validation Failed');
expect(responseBody.detail).toBe('\'files\' must be an object');
done();
}
promise.then(expectations, expectations).catch(expectations);
});
});
});

View File

@ -0,0 +1,119 @@
/**
* @jest-environment node
*/
import { Request, Response } from 'jest-express';
import Project, { createMock, createInstanceMock } from '../../../models/project';
import User from '../../../models/user';
import deleteProject from '../../project.controller/deleteProject';
import { deleteObjectsFromS3 } from '../../aws.controller';
jest.mock('../../../models/project');
jest.mock('../../aws.controller');
describe('project.controller', () => {
describe('deleteProject()', () => {
let ProjectMock;
let ProjectInstanceMock;
beforeEach(() => {
ProjectMock = createMock();
ProjectInstanceMock = createInstanceMock();
});
afterEach(() => {
ProjectMock.restore();
ProjectInstanceMock.restore();
});
it('returns 403 if project is not owned by authenticated user', (done) => {
const user = new User();
const project = new Project();
project.user = user;
const request = new Request();
request.setParams({ project_id: project._id });
request.user = { _id: 'abc123' };
const response = new Response();
ProjectMock
.expects('findById')
.resolves(project);
const promise = deleteProject(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(403);
expect(response.json).toHaveBeenCalledWith({
message: 'Authenticated user does not match owner of project'
});
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('returns 404 if project does not exist', (done) => {
const user = new User();
const project = new Project();
project.user = user;
const request = new Request();
request.setParams({ project_id: project._id });
request.user = { _id: 'abc123' };
const response = new Response();
ProjectMock
.expects('findById')
.resolves(null);
const promise = deleteProject(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(404);
expect(response.json).toHaveBeenCalledWith({
message: 'Project with that id does not exist'
});
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('deletes project and dependent files from S3 ', (done) => {
const user = new User();
const project = new Project();
project.user = user;
const request = new Request();
request.setParams({ project_id: project._id });
request.user = { _id: user._id };
const response = new Response();
ProjectMock
.expects('findById')
.resolves(project);
ProjectInstanceMock.expects('remove')
.yields();
const promise = deleteProject(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(200);
expect(response.json).not.toHaveBeenCalled();
expect(deleteObjectsFromS3).toHaveBeenCalled();
done();
}
promise.then(expectations, expectations).catch(expectations);
});
});
});

View File

@ -0,0 +1,151 @@
/**
* @jest-environment node
*/
import { Request, Response } from 'jest-express';
import { createMock } from '../../../models/user';
import getProjectsForUser, { apiGetProjectsForUser } from '../../project.controller/getProjectsForUser';
jest.mock('../../../models/user');
jest.mock('../../aws.controller');
describe('project.controller', () => {
let UserMock;
beforeEach(() => {
UserMock = createMock();
});
afterEach(() => {
UserMock.restore();
});
describe('getProjectsForUser()', () => {
it('returns empty array user not supplied as parameter', (done) => {
const request = new Request();
request.setParams({});
const response = new Response();
const promise = getProjectsForUser(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(200);
expect(response.json).toHaveBeenCalledWith([]);
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('returns 404 if user does not exist', (done) => {
const request = new Request();
request.setParams({ username: 'abc123' });
const response = new Response();
UserMock
.expects('findOne')
.withArgs({ username: 'abc123' })
.yields(null, null);
const promise = getProjectsForUser(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(404);
expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('returns 500 on other errors', (done) => {
const request = new Request();
request.setParams({ username: 'abc123' });
const response = new Response();
UserMock
.expects('findOne')
.withArgs({ username: 'abc123' })
.yields(new Error(), null);
const promise = getProjectsForUser(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(500);
expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
});
describe('apiGetProjectsForUser()', () => {
it('returns validation error if user id not provided', (done) => {
const request = new Request();
request.setParams({});
const response = new Response();
const promise = apiGetProjectsForUser(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(422);
expect(response.json).toHaveBeenCalledWith({
message: 'Username not provided'
});
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('returns 404 if user does not exist', (done) => {
const request = new Request();
request.setParams({ username: 'abc123' });
const response = new Response();
UserMock
.expects('findOne')
.withArgs({ username: 'abc123' })
.yields(null, null);
const promise = apiGetProjectsForUser(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(404);
expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
it('returns 500 on other errors', (done) => {
const request = new Request();
request.setParams({ username: 'abc123' });
const response = new Response();
UserMock
.expects('findOne')
.withArgs({ username: 'abc123' })
.yields(new Error(), null);
const promise = apiGetProjectsForUser(request, response);
function expectations() {
expect(response.status).toHaveBeenCalledWith(500);
expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' });
done();
}
promise.then(expectations, expectations).catch(expectations);
});
});
});

View File

@ -1,4 +1,5 @@
import Project from '../../models/project';
import { toModel, FileValidationError, ProjectValidationError } from '../../domain-objects/Project';
export default function createProject(req, res) {
let projectValues = {
@ -30,3 +31,67 @@ export default function createProject(req, res) {
.then(populateUserData)
.catch(sendFailure);
}
// TODO: What happens if you don't supply any files?
export function apiCreateProject(req, res) {
const params = Object.assign({ user: req.user._id }, req.body);
function sendValidationErrors(err, type, code = 422) {
res.status(code).json({
message: `${type} Validation Failed`,
detail: err.message,
errors: err.files,
});
}
// TODO: Error handling to match spec
function sendFailure(err) {
res.status(500).end();
}
function handleErrors(err) {
if (err instanceof FileValidationError) {
sendValidationErrors(err, 'File', err.code);
} else if (err instanceof ProjectValidationError) {
sendValidationErrors(err, 'Sketch', err.code);
} else {
sendFailure();
}
}
function checkUserHasPermission() {
if (req.user.username !== req.params.username) {
console.log('no permission');
const error = new ProjectValidationError(`'${req.user.username}' does not have permission to create for '${req.params.username}'`);
error.code = 401;
throw error;
}
}
try {
checkUserHasPermission();
const model = toModel(params);
return model.isSlugUnique()
.then(({ isUnique, conflictingIds }) => {
if (isUnique) {
return model.save()
.then((newProject) => {
res.status(201).json({ id: newProject.id });
});
}
const error = new ProjectValidationError(`Slug "${model.slug}" is not unique. Check ${conflictingIds.join(', ')}`);
error.code = 409;
throw error;
})
.then(checkUserHasPermission)
.catch(handleErrors);
} catch (err) {
handleErrors(err);
return Promise.reject(err);
}
}

View File

@ -0,0 +1,57 @@
import isBefore from 'date-fns/is_before';
import Project from '../../models/project';
import { deleteObjectsFromS3, getObjectKey } from '../aws.controller';
import createApplicationErrorClass from '../../utils/createApplicationErrorClass';
const ProjectDeletionError = createApplicationErrorClass('ProjectDeletionError');
function deleteFilesFromS3(files) {
deleteObjectsFromS3(files.filter((file) => {
if (file.url) {
if (!process.env.S3_DATE || (
process.env.S3_DATE &&
isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) {
return true;
}
}
return false;
})
.map(file => getObjectKey(file.url)));
}
export default function deleteProject(req, res) {
function sendFailure(error) {
res.status(error.code).json({ message: error.message });
}
function sendProjectNotFound() {
sendFailure(new ProjectDeletionError('Project with that id does not exist', { code: 404 }));
}
function handleProjectDeletion(project) {
if (project == null) {
sendProjectNotFound();
return;
}
if (!project.user.equals(req.user._id)) {
sendFailure(new ProjectDeletionError('Authenticated user does not match owner of project', { code: 403 }));
return;
}
deleteFilesFromS3(project.files);
project.remove((removeProjectError) => {
if (removeProjectError) {
sendProjectNotFound();
return;
}
res.status(200).end();
});
}
return Project.findById(req.params.project_id)
.then(handleProjectDeletion)
.catch(sendFailure);
}

View File

@ -0,0 +1,72 @@
import Project from '../../models/project';
import User from '../../models/user';
import { toApi as toApiProjectObject } from '../../domain-objects/Project';
import createApplicationErrorClass from '../../utils/createApplicationErrorClass';
const UserNotFoundError = createApplicationErrorClass('UserNotFoundError');
function getProjectsForUserName(username) {
return new Promise((resolve, reject) => {
User.findOne({ username }, (err, user) => {
if (err) {
reject(err);
return;
}
if (!user) {
reject(new UserNotFoundError());
return;
}
Project.find({ user: user._id })
.sort('-createdAt')
.select('name files id createdAt updatedAt')
.exec((innerErr, projects) => {
if (innerErr) {
reject(innerErr);
return;
}
resolve(projects);
});
});
});
}
export default function getProjectsForUser(req, res) {
if (req.params.username) {
return getProjectsForUserName(req.params.username)
.then(projects => res.json(projects))
.catch((err) => {
if (err instanceof UserNotFoundError) {
res.status(404).json({ message: 'User with that username does not exist.' });
} else {
res.status(500).json({ message: 'Error fetching projects' });
}
});
}
// could just move this to client side
res.status(200).json([]);
return Promise.resolve();
}
export function apiGetProjectsForUser(req, res) {
if (req.params.username) {
return getProjectsForUserName(req.params.username)
.then((projects) => {
const asApiObjects = projects.map(p => toApiProjectObject(p));
res.json({ sketches: asApiObjects });
})
.catch((err) => {
if (err instanceof UserNotFoundError) {
res.status(404).json({ message: 'User with that username does not exist.' });
} else {
res.status(500).json({ message: 'Error fetching projects' });
}
});
}
res.status(422).json({ message: 'Username not provided' });
return Promise.resolve();
}

View File

@ -1,73 +1,40 @@
/* @jest-environment node */
import last from 'lodash/last';
import { Request, Response } from 'jest-express';
import User, { createMock, createInstanceMock } from '../../../models/user';
import { createApiKey, removeApiKey } from '../../user.controller/apiKey';
jest.mock('../../../models/user');
/*
Create a mock object representing an express Response
*/
const createResponseMock = function createResponseMock(done) {
const json = jest.fn(() => {
if (done) { done(); }
});
const status = jest.fn(() => ({ json }));
return {
status,
json
};
};
/*
Create a mock of the mongoose User model
*/
const createUserMock = function createUserMock() {
const apiKeys = [];
let nextId = 0;
apiKeys.push = ({ label, hashedKey }) => {
const id = nextId;
nextId += 1;
const publicFields = { id, label };
const allFields = { ...publicFields, hashedKey };
Object.defineProperty(allFields, 'toObject', {
value: () => publicFields,
enumerable: false
});
return Array.prototype.push.call(apiKeys, allFields);
};
apiKeys.pull = ({ _id }) => {
const index = apiKeys.findIndex(({ id }) => id === _id);
return apiKeys.splice(index, 1);
};
return {
apiKeys,
save: jest.fn(callback => callback())
};
};
const User = require('../../../models/user').default;
describe('user.controller', () => {
let UserMock;
let UserInstanceMock;
beforeEach(() => {
User.__setFindById(null, null);
UserMock = createMock();
UserInstanceMock = createInstanceMock();
});
afterEach(() => {
UserMock.restore();
UserInstanceMock.restore();
});
describe('createApiKey', () => {
it('returns an error if user doesn\'t exist', () => {
const request = { user: { id: '1234' } };
const response = createResponseMock();
const response = new Response();
UserMock
.expects('findById')
.withArgs('1234')
.yields(null, null);
createApiKey(request, response);
expect(User.findById.mock.calls[0][0]).toBe('1234');
expect(response.status).toHaveBeenCalledWith(404);
expect(response.json).toHaveBeenCalledWith({
error: 'User not found'
@ -75,10 +42,13 @@ describe('user.controller', () => {
});
it('returns an error if label not provided', () => {
User.__setFindById(undefined, createUserMock());
const request = { user: { id: '1234' }, body: {} };
const response = createResponseMock();
const response = new Response();
UserMock
.expects('findById')
.withArgs('1234')
.yields(null, new User());
createApiKey(request, response);
@ -89,46 +59,59 @@ describe('user.controller', () => {
});
it('returns generated API key to the user', (done) => {
let response;
const request = new Request();
request.setBody({ label: 'my key' });
request.user = { id: '1234' };
const request = {
user: { id: '1234' },
body: { label: 'my key' }
};
const response = new Response();
const user = createUserMock();
const user = new User();
const checkExpecations = () => {
UserMock
.expects('findById')
.withArgs('1234')
.yields(null, user);
UserInstanceMock.expects('save')
.yields();
function expectations() {
const lastKey = last(user.apiKeys);
expect(lastKey.label).toBe('my key');
expect(typeof lastKey.hashedKey).toBe('string');
expect(response.json).toHaveBeenCalledWith({
apiKeys: [
{ id: 0, label: 'my key', token: lastKey.hashedKey }
]
const responseData = response.json.mock.calls[0][0];
expect(responseData.apiKeys.length).toBe(1);
expect(responseData.apiKeys[0]).toMatchObject({
label: 'my key',
token: lastKey.hashedKey,
lastUsedAt: undefined,
createdAt: undefined
});
done();
};
}
response = createResponseMock(checkExpecations);
const promise = createApiKey(request, response);
User.__setFindById(undefined, user);
createApiKey(request, response);
promise.then(expectations, expectations).catch(expectations);
});
});
describe('removeApiKey', () => {
it('returns an error if user doesn\'t exist', () => {
const request = { user: { id: '1234' } };
const response = createResponseMock();
const response = new Response();
UserMock
.expects('findById')
.withArgs('1234')
.yields(null, null);
removeApiKey(request, response);
expect(User.findById.mock.calls[0][0]).toBe('1234');
expect(response.status).toHaveBeenCalledWith(404);
expect(response.json).toHaveBeenCalledWith({
error: 'User not found'
@ -140,10 +123,13 @@ describe('user.controller', () => {
user: { id: '1234' },
params: { keyId: 'not-a-real-key' }
};
const response = createResponseMock();
const response = new Response();
const user = new User();
const user = createUserMock();
User.__setFindById(undefined, user);
UserMock
.expects('findById')
.withArgs('1234')
.yields(null, user);
removeApiKey(request, response);
@ -153,27 +139,41 @@ describe('user.controller', () => {
});
});
it.skip('removes key if it exists', () => {
const request = {
user: { id: '1234' },
params: { keyId: 0 }
};
const response = createResponseMock();
const user = createUserMock();
it('removes key if it exists', () => {
const user = new User();
user.apiKeys.push({ label: 'first key' }); // id 0
user.apiKeys.push({ label: 'second key' }); // id 1
User.__setFindById(undefined, user);
const firstKeyId = user.apiKeys[0]._id.toString();
const secondKeyId = user.apiKeys[1]._id.toString();
const request = {
user: { id: '1234' },
params: { keyId: firstKeyId }
};
const response = new Response();
UserMock
.expects('findById')
.withArgs('1234')
.yields(null, user);
UserInstanceMock
.expects('save')
.yields();
removeApiKey(request, response);
expect(response.status).toHaveBeenCalledWith(200);
expect(response.json).toHaveBeenCalledWith({
apiKeys: [
{ id: 1, label: 'second key' }
]
const responseData = response.json.mock.calls[0][0];
expect(responseData.apiKeys.length).toBe(1);
expect(responseData.apiKeys[0]).toMatchObject({
id: secondKeyId,
label: 'second key',
lastUsedAt: undefined,
createdAt: undefined
});
});
});

View File

@ -19,67 +19,85 @@ function generateApiKey() {
}
export function createApiKey(req, res) {
User.findById(req.user.id, async (err, user) => {
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
return new Promise((resolve, reject) => {
function sendFailure(code, error) {
res.status(code).json({ error });
resolve();
}
if (!req.body.label) {
res.status(400).json({ error: 'Expected field \'label\' was not present in request body' });
return;
}
const keyToBeHashed = await generateApiKey();
const addedApiKeyIndex = user.apiKeys.push({ label: req.body.label, hashedKey: keyToBeHashed });
user.save((saveErr) => {
if (saveErr) {
res.status(500).json({ error: saveErr });
User.findById(req.user.id, async (err, user) => {
if (!user) {
sendFailure(404, 'User not found');
return;
}
const apiKeys = user.apiKeys
.map((apiKey, index) => {
const fields = apiKey.toObject();
const shouldIncludeToken = index === addedApiKeyIndex - 1;
if (!req.body.label) {
sendFailure(400, 'Expected field \'label\' was not present in request body');
return;
}
return shouldIncludeToken ?
{ ...fields, token: keyToBeHashed } :
fields;
});
const keyToBeHashed = await generateApiKey();
res.json({ apiKeys });
const addedApiKeyIndex = user.apiKeys.push({ label: req.body.label, hashedKey: keyToBeHashed });
user.save((saveErr) => {
if (saveErr) {
sendFailure(500, saveErr);
return;
}
const apiKeys = user.apiKeys
.map((apiKey, index) => {
const fields = apiKey.toObject();
const shouldIncludeToken = index === addedApiKeyIndex - 1;
return shouldIncludeToken ?
{ ...fields, token: keyToBeHashed } :
fields;
});
res.json({ apiKeys });
resolve();
});
});
});
}
export function removeApiKey(req, res) {
User.findById(req.user.id, (err, user) => {
if (err) {
res.status(500).json({ error: err });
return;
}
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
const keyToDelete = user.apiKeys.find(key => key.id === req.params.keyId);
if (!keyToDelete) {
res.status(404).json({ error: 'Key does not exist for user' });
return;
return new Promise((resolve, reject) => {
function sendFailure(code, error) {
res.status(code).json({ error });
resolve();
}
user.apiKeys.pull({ _id: req.params.keyId });
user.save((saveErr) => {
if (saveErr) {
res.status(500).json({ error: saveErr });
User.findById(req.user.id, (err, user) => {
if (err) {
sendFailure(500, err);
return;
}
res.status(200).json({ apiKeys: user.apiKeys });
if (!user) {
sendFailure(404, 'User not found');
return;
}
const keyToDelete = user.apiKeys.find(key => key.id === req.params.keyId);
if (!keyToDelete) {
sendFailure(404, 'Key does not exist for user');
return;
}
user.apiKeys.pull({ _id: req.params.keyId });
user.save((saveErr) => {
if (saveErr) {
sendFailure(500, saveErr);
return;
}
res.status(200).json({ apiKeys: user.apiKeys });
resolve();
});
});
});
}

View File

@ -0,0 +1,133 @@
import isPlainObject from 'lodash/isPlainObject';
import pick from 'lodash/pick';
import Project from '../models/project';
import createId from '../utils/createId';
import createApplicationErrorClass from '../utils/createApplicationErrorClass';
import createDefaultFiles from './createDefaultFiles';
export const FileValidationError = createApplicationErrorClass('FileValidationError');
export const ProjectValidationError = createApplicationErrorClass('ProjectValidationError');
/**
* This converts between a mongoose Project model
* and the public API Project object properties
*
*/
export function toApi(model) {
return {
id: model.id,
name: model.name,
};
}
/**
* Transforms a tree of files matching the APIs DirectoryContents
* format into the data structure stored in mongodb
*
* - flattens the tree into an array of file/folders
* - each file/folder gets a generated BSON-ID
* - each folder has a `children` array containing the IDs of it's children
*/
function transformFilesInner(tree = {}, parentNode) {
const files = [];
const errors = [];
Object.entries(tree).forEach(([name, params]) => {
const id = createId();
const isFolder = params.files != null;
if (isFolder) {
const folder = {
_id: id,
name,
fileType: 'folder',
children: [] // Initialise an empty folder
};
files.push(folder);
// The recursion will return a list of child files/folders
// It will also push the child's id into `folder.children`
const subFolder = transformFilesInner(params.files, folder);
files.push(...subFolder.files);
errors.push(...subFolder.errors);
} else {
const file = {
_id: id,
name,
fileType: 'file'
};
if (typeof params.url === 'string') {
file.url = params.url;
} else if (typeof params.content === 'string') {
file.content = params.content;
} else {
errors.push({ name, message: 'missing \'url\' or \'content\'' });
}
files.push(file);
}
// Push this child's ID onto it's parent's list
// of children
if (parentNode != null) {
parentNode.children.push(id);
}
});
return { files, errors };
}
export function transformFiles(tree = {}) {
const withRoot = {
root: {
files: tree
}
};
const { files, errors } = transformFilesInner(withRoot);
if (errors.length > 0) {
const message = `${errors.length} files failed validation. See error.files for individual errors.
Errors:
${errors.map(e => `* ${e.name}: ${e.message}`).join('\n')}
`;
const error = new FileValidationError(message);
error.files = errors;
throw error;
}
return files;
}
export function containsRootHtmlFile(tree) {
return Object.keys(tree).find(name => /\.html$/.test(name)) != null;
}
/**
* This converts between the public API's Project object
* properties and a mongoose Project model
*
*/
export function toModel(object) {
let files = [];
let tree = object.files;
if (isPlainObject(tree)) {
if (!containsRootHtmlFile(tree)) {
tree = Object.assign(createDefaultFiles(), tree);
}
files = transformFiles(tree);
} else {
throw new FileValidationError('\'files\' must be an object');
}
const projectValues = pick(object, ['user', 'name', 'slug']);
projectValues.files = files;
return new Project(projectValues);
}

View File

@ -0,0 +1,385 @@
import find from 'lodash/find';
import { containsRootHtmlFile, toModel, transformFiles, FileValidationError } from '../Project';
jest.mock('../../utils/createId');
// TODO: File name validation
// TODO: File extension validation
//
describe('domain-objects/Project', () => {
describe('containsRootHtmlFile', () => {
it('returns true for at least one root .html', () => {
expect(containsRootHtmlFile({ 'index.html': {} })).toBe(true);
expect(containsRootHtmlFile({ 'another-one.html': {} })).toBe(true);
expect(containsRootHtmlFile({ 'one.html': {}, 'two.html': {} })).toBe(true);
expect(containsRootHtmlFile({ 'one.html': {}, 'sketch.js': {} })).toBe(true);
});
it('returns false anything else', () => {
expect(containsRootHtmlFile({ 'sketch.js': {} })).toBe(false);
});
it('ignores nested html', () => {
expect(containsRootHtmlFile({
examples: {
files: {
'index.html': {}
}
}
})).toBe(false);
});
});
describe('toModel', () => {
it('filters extra properties', () => {
const params = {
name: 'My sketch',
extraThing: 'oopsie',
files: {}
};
const model = toModel(params);
expect(model.name).toBe('My sketch');
expect(model.extraThing).toBeUndefined();
});
it('throws FileValidationError', () => {
const params = {
files: {
'index.html': {} // missing content or url
}
};
expect(() => toModel(params)).toThrowError(FileValidationError);
});
it('throws if files is not an object', () => {
const params = {
files: []
};
expect(() => toModel(params)).toThrowError(FileValidationError);
});
it('creates default index.html and dependent files if no root .html is provided', () => {
const params = {
files: {}
};
const { files } = toModel(params);
expect(files.length).toBe(4);
expect(find(files, { name: 'index.html' })).not.toBeUndefined();
expect(find(files, { name: 'sketch.js' })).not.toBeUndefined();
expect(find(files, { name: 'style.css' })).not.toBeUndefined();
});
it('does not create default files if any root .html is provided', () => {
const params = {
files: {
'example.html': {
content: '<html><body>Hello!</body></html>'
}
}
};
const { files } = toModel(params);
expect(files.length).toBe(2);
expect(find(files, { name: 'example.html' })).not.toBeUndefined();
expect(find(files, { name: 'index.html' })).toBeUndefined();
expect(find(files, { name: 'sketch.js' })).toBeUndefined();
expect(find(files, { name: 'style.css' })).toBeUndefined();
});
it('does not overwrite default CSS and JS of the same name if provided', () => {
const params = {
files: {
'sketch.js': {
content: 'const sketch = true;'
},
'style.css': {
content: 'body { outline: 10px solid red; }'
}
}
};
const { files } = toModel(params);
expect(files.length).toBe(4);
expect(find(files, { name: 'index.html' })).not.toBeUndefined();
const sketchFile = find(files, { name: 'sketch.js' });
expect(sketchFile.content).toBe('const sketch = true;');
const cssFile = find(files, { name: 'style.css' });
expect(cssFile.content).toBe('body { outline: 10px solid red; }');
});
});
});
describe('transformFiles', () => {
beforeEach(() => {
// eslint-disable-next-line global-require
const { resetMockCreateId } = require('../../utils/createId');
resetMockCreateId();
});
it('creates an empty root with no data', () => {
const tree = {};
expect(transformFiles(tree)).toEqual([{
_id: '0',
fileType: 'folder',
name: 'root',
children: []
}]);
});
it('converts tree-shaped files into list', () => {
const tree = {
'index.html': {
content: 'some contents',
}
};
expect(transformFiles(tree)).toEqual([
{
_id: '0',
fileType: 'folder',
name: 'root',
children: ['1']
},
{
_id: '1',
content: 'some contents',
fileType: 'file',
name: 'index.html'
}
]);
});
it('uses file url over content', () => {
const tree = {
'script.js': {
url: 'http://example.net/something.js'
}
};
expect(transformFiles(tree)).toEqual([
{
_id: '0',
fileType: 'folder',
name: 'root',
children: ['1']
},
{
_id: '1',
url: 'http://example.net/something.js',
fileType: 'file',
name: 'script.js'
}
]);
});
it('creates folders', () => {
const tree = {
'a-folder': {
files: {}
},
};
expect(transformFiles(tree)).toEqual([
{
_id: '0',
fileType: 'folder',
name: 'root',
children: ['1']
},
{
_id: '1',
children: [],
fileType: 'folder',
name: 'a-folder'
}
]);
});
it('walks the tree processing files', () => {
const tree = {
'index.html': {
content: 'some contents',
},
'a-folder': {
files: {
'data.csv': {
content: 'this,is,data'
},
'another.txt': {
content: 'blah blah'
}
}
},
};
expect(transformFiles(tree)).toEqual([
{
_id: '0',
fileType: 'folder',
name: 'root',
children: ['1', '2']
},
{
_id: '1',
name: 'index.html',
fileType: 'file',
content: 'some contents'
},
{
_id: '2',
name: 'a-folder',
fileType: 'folder',
children: ['3', '4']
},
{
_id: '3',
name: 'data.csv',
fileType: 'file',
content: 'this,is,data'
},
{
_id: '4',
name: 'another.txt',
fileType: 'file',
content: 'blah blah'
}
]);
});
it('handles deep nesting', () => {
const tree = {
first: {
files: {
second: {
files: {
third: {
files: {
'hello.js': {
content: 'world!'
}
}
}
}
}
}
},
};
expect(transformFiles(tree)).toEqual([
{
_id: '0',
fileType: 'folder',
name: 'root',
children: ['1']
},
{
_id: '1',
name: 'first',
fileType: 'folder',
children: ['2']
},
{
_id: '2',
name: 'second',
fileType: 'folder',
children: ['3']
},
{
_id: '3',
name: 'third',
fileType: 'folder',
children: ['4']
},
{
_id: '4',
name: 'hello.js',
fileType: 'file',
content: 'world!'
}
]);
});
it('allows duplicate names in different folder', () => {
const tree = {
'index.html': {
content: 'some contents',
},
'data': {
files: {
'index.html': {
content: 'different file'
}
}
},
};
expect(transformFiles(tree)).toEqual([
{
_id: '0',
fileType: 'folder',
name: 'root',
children: ['1', '2']
},
{
_id: '1',
name: 'index.html',
fileType: 'file',
content: 'some contents'
},
{
_id: '2',
name: 'data',
fileType: 'folder',
children: ['3']
},
{
_id: '3',
name: 'index.html',
fileType: 'file',
content: 'different file'
}
]);
});
it('validates files', () => {
const tree = {
'index.html': {} // missing `content`
};
expect(() => transformFiles(tree)).toThrowError(FileValidationError);
});
it('collects all file validation errors', () => {
const tree = {
'index.html': {}, // missing `content`
'something.js': {} // also missing `content`
};
try {
transformFiles(tree);
// Should not get here
throw new Error('should have thrown before this point');
} catch (err) {
expect(err).toBeInstanceOf(FileValidationError);
expect(err.files).toEqual([
{ name: 'index.html', message: 'missing \'url\' or \'content\'' },
{ name: 'something.js', message: 'missing \'url\' or \'content\'' }
]);
}
});
});

View File

@ -0,0 +1,48 @@
const defaultSketch = `function setup() {
createCanvas(400, 400);
}
function draw() {
background(220);
}`;
const defaultHTML =
`<!DOCTYPE html>
<html>
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.8.0/p5.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.8.0/addons/p5.dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.8.0/addons/p5.sound.min.js"></script>
<link rel="stylesheet" type="text/css" href="style.css">
<meta charset="utf-8" />
</head>
<body>
<script src="sketch.js"></script>
</body>
</html>
`;
const defaultCSS =
`html, body {
margin: 0;
padding: 0;
}
canvas {
display: block;
}
`;
export default function createDefaultFiles() {
return {
'index.html': {
content: defaultHTML
},
'style.css': {
content: defaultCSS
},
'sketch.js': {
content: defaultSketch
}
};
}

View File

@ -11,6 +11,24 @@ export function createMock() {
return sinon.mock(Project);
}
// Wraps the Project.prototype i.e. the
// instance methods in a mock so
// Project.save() can be mocked
export function createInstanceMock() {
// See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro
Object.defineProperty(Project.prototype, 'save', {
value: Project.prototype.save,
configurable: true,
});
Object.defineProperty(Project.prototype, 'remove', {
value: Project.prototype.remove,
configurable: true,
});
return sinon.mock(Project.prototype);
}
// Re-export the model, it will be
// altered by mockingoose whenever
// we call methods on the MockConfig

View File

@ -1,12 +1,31 @@
let __err = null;
let __user = null;
import sinon from 'sinon';
import 'sinon-mongoose';
export default {
__setFindById(err, user) {
__err = err;
__user = user;
},
findById: jest.fn(async (id, callback) => {
callback(__err, __user);
})
};
// Import the actual model to be mocked
const User = jest.requireActual('../user').default;
// Wrap User in a sinon mock
// The returned object is used to configure
// the mocked model's behaviour
export function createMock() {
return sinon.mock(User);
}
// Wraps the User.prototype i.e. the
// instance methods in a mock so
// User.save() can be mocked
export function createInstanceMock() {
// See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro
Object.defineProperty(User.prototype, 'save', {
value: User.prototype.save,
configurable: true,
});
return sinon.mock(User.prototype);
}
// Re-export the model, it will be
// altered by mockingoose whenever
// we call methods on the MockConfig
export default User;

View File

@ -49,8 +49,43 @@ projectSchema.set('toJSON', {
projectSchema.pre('save', function generateSlug(next) {
const project = this;
project.slug = slugify(project.name, '_');
if (!project.slug) {
project.slug = slugify(project.name, '_');
}
return next();
});
/**
* Check if slug is unique for this user's projects
*/
projectSchema.methods.isSlugUnique = async function isSlugUnique(cb) {
const project = this;
const hasCallback = typeof cb === 'function';
try {
const docsWithSlug = await project.model('Project')
.find({ user: project.user, slug: project.slug }, '_id')
.exec();
const result = {
isUnique: docsWithSlug.length === 0,
conflictingIds: docsWithSlug.map(d => d._id) || []
};
if (hasCallback) {
cb(null, result);
}
return result;
} catch (err) {
if (hasCallback) {
cb(err, null);
}
throw err;
}
};
export default mongoose.model('Project', projectSchema);

View File

@ -0,0 +1,27 @@
import { Router } from 'express';
import passport from 'passport';
import * as ProjectController from '../controllers/project.controller';
const router = new Router();
router.get(
'/:username/sketches',
passport.authenticate('basic', { session: false }),
ProjectController.apiGetProjectsForUser
);
router.post(
'/:username/sketches',
passport.authenticate('basic', { session: false }),
ProjectController.apiCreateProject
);
// NOTE: Currently :username will not be checked for ownership
// only the project's owner in the database.
router.delete(
'/:username/sketches/:project_id',
passport.authenticate('basic', { session: false }),
ProjectController.deleteProject
);
export default router;

View File

@ -1,11 +1,7 @@
import fs from 'fs';
import rp from 'request-promise';
import Q from 'q';
import mongoose from 'mongoose';
import objectID from 'bson-objectid';
import shortid from 'shortid';
import User from '../models/user';
import Project from '../models/project';
import { ok } from 'assert';
// TODO: Change branchName if necessary
const branchName = 'release';
@ -14,11 +10,20 @@ const baseUrl = 'https://api.github.com/repos/ml5js/ml5-examples/contents';
const clientId = process.env.GITHUB_ID;
const clientSecret = process.env.GITHUB_SECRET;
const editorUsername = process.env.ML5_EXAMPLES_USERNAME;
const personalAccessToken = process.env.EDITOR_API_ACCESS_TOKEN;
const editorApiUrl = process.env.EDITOR_API_URL;
const headers = {
'User-Agent': 'p5js-web-editor/0.0.1'
};
const requestOptions = {
ok(clientId, 'GITHUB_ID is required');
ok(clientSecret, 'GITHUB_SECRET is required');
ok(editorUsername, 'ML5_EXAMPLES_USERNAME is required');
ok(personalAccessToken, 'EDITOR_API_ACCESS_TOKEN is required');
ok(editorApiUrl, 'EDITOR_API_URL is required');
//
const githubRequestOptions = {
url: baseUrl,
qs: {
client_id: clientId,
@ -29,14 +34,15 @@ const requestOptions = {
json: true
};
const mongoConnectionString = process.env.MONGO_URL;
mongoose.connect(mongoConnectionString, {
useMongoClient: true
});
mongoose.connection.on('error', () => {
console.error('MongoDB Connection Error. Please make sure that MongoDB is running.');
process.exit(1);
});
const editorRequestOptions = {
url: `${editorApiUrl}/${editorUsername}`,
method: 'GET',
headers: {
...headers,
Authorization: `Basic ${Buffer.from(`${editorUsername}:${personalAccessToken}`).toString('base64')}`
},
json: true
};
/**
* ---------------------------------------------------------
@ -51,12 +57,52 @@ function flatten(list) {
return list.reduce((a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []);
}
/**
* Fetch data for a single HTML/JS file, or return
* an url to the file's CDN location
*/
async function fetchFileContent(item) {
const { name } = item;
const file = { url: item.url };
// if it is an html or js file
if (
(file.url != null && name.endsWith('.html')) ||
name.endsWith('.js')
) {
const options = Object.assign({}, githubRequestOptions);
options.url = `${file.url}`;
if (
options.url !== undefined ||
options.url !== null ||
options.url !== ''
) {
file.content = await rp(options);
// NOTE: remove the URL property if there's content
// Otherwise the p5 editor will try to pull from that url
if (file.content !== null) delete file.url;
}
return file;
// if it is NOT an html or js file
}
if (file.url) {
const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${file.url.split(branchName)[1]}`;
file.url = cdnRef;
}
return file;
}
/**
* STEP 1: Get the top level cateogories
*/
async function getCategories() {
try {
const options = Object.assign({}, requestOptions);
const options = Object.assign({}, githubRequestOptions);
options.url = `${options.url}/p5js${branchRef}`;
const results = await rp(options);
@ -76,13 +122,18 @@ async function getCategoryExamples(sketchRootList) {
const output = [];
const sketchRootCategories = sketchRootList.map(async (categories) => {
// let options = Object.assign({url: `${requestOptions.url}/${categories.path}${branchRef}`}, requestOptions)
const options = Object.assign({}, requestOptions);
const options = Object.assign({}, githubRequestOptions);
options.url = `${options.url}${categories.path}${branchRef}`;
// console.log(options)
const sketchDirs = await rp(options);
const result = flatten(sketchDirs);
return result;
try {
const result = flatten(sketchDirs);
return result;
} catch (err) {
return [];
}
});
const sketchList = await Q.all(sketchRootCategories);
@ -107,7 +158,7 @@ async function traverseSketchTree(parentObject) {
return output;
}
// let options = `https://api.github.com/repos/ml5js/ml5-examples/contents/${sketches.path}${branchRef}`
const options = Object.assign({}, requestOptions);
const options = Object.assign({}, githubRequestOptions);
options.url = `${options.url}${parentObject.path}${branchRef}`;
output.tree = await rp(options);
@ -124,7 +175,6 @@ async function traverseSketchTree(parentObject) {
* @param {*} categoryExamples - all of the categories in an array
*/
async function traverseSketchTreeAll(categoryExamples) {
// const sketches = categoryExamples.map(async sketch => await traverseSketchTree(sketch));
const sketches = categoryExamples.map(async sketch => traverseSketchTree(sketch));
const result = await Q.all(sketches);
@ -139,37 +189,24 @@ function traverseAndFormat(parentObject) {
const parent = Object.assign({}, parentObject);
if (!parentObject.tree) {
const newid = objectID().toHexString();
// returns the files
return {
name: parent.name,
url: parent.download_url,
content: null,
id: newid,
_id: newid,
fileType: 'file'
url: parent.download_url
};
}
const subdir = parentObject.tree.map((item) => {
const newid = objectID().toHexString();
if (!item.tree) {
// returns the files
return {
name: item.name,
url: item.download_url,
content: null,
id: newid,
_id: newid,
fileType: 'file'
url: item.download_url
};
}
const feat = {
name: item.name,
id: newid,
_id: newid,
fileType: 'folder',
children: traverseAndFormat(item)
};
return feat;
@ -178,69 +215,27 @@ function traverseAndFormat(parentObject) {
}
/**
* Traverse the tree and flatten for project.files[]
* Traverse the tree and download all the content,
* transforming into an object keyed by file/directory name
* @param {*} projectFileTree
*/
function traverseAndFlatten(projectFileTree) {
const r = objectID().toHexString();
async function traverseAndDownload(projectFileTree) {
return projectFileTree.reduce(
async (previousPromise, item, idx) => {
const result = await previousPromise;
const projectRoot = {
name: 'root',
id: r,
_id: r,
children: [],
fileType: 'folder'
};
let currentParent;
const output = projectFileTree.reduce(
(result, item, idx) => {
if (idx < projectFileTree.length) {
projectRoot.children.push(item.id);
}
if (item.fileType === 'file') {
if (item.name === 'sketch.js') {
item.isSelectedFile = true;
}
result.push(item);
}
// here's where the magic happens *twinkles*
if (item.fileType === 'folder') {
// recursively go down the tree of children
currentParent = traverseAndFlatten(item.children);
// the above will return an array of the children files
// concatenate that with the results
result = result.concat(currentParent); // eslint-disable-line no-param-reassign
// since we want to get the children ids,
// we can map the child ids to the current item
// then push that to our result array to get
// our flat files array.
item.children = item.children.map(child => child.id);
result.push(item);
if (Array.isArray(item.children)) {
result[item.name] = {
files: await traverseAndDownload(item.children)
};
} else {
result[item.name] = await fetchFileContent(item);
}
return result;
},
[projectRoot]
{}
);
// Kind of hacky way to remove all roots other than the starting one
let counter = 0;
output.forEach((item, idx) => {
if (item.name === 'root') {
if (counter === 0) {
counter += 1;
} else {
output.splice(idx, 1);
counter += 1;
}
}
});
return output;
}
/**
@ -249,16 +244,14 @@ function traverseAndFlatten(projectFileTree) {
* @param {*} sketch
* @param {*} user
*/
function formatSketchForStorage(sketch, user) {
const newProject = new Project({
_id: shortid.generate(),
async function formatSketchForStorage(sketch, user) {
const newProject = {
name: sketch.name,
user: user._id,
files: [] // <== add files to this array as file objects and add _id reference to children of root
});
files: {} // <== add files to this object
};
let projectFiles = traverseAndFormat(sketch);
projectFiles = traverseAndFlatten(projectFiles);
projectFiles = await traverseAndDownload(projectFiles);
newProject.files = projectFiles;
return newProject;
}
@ -271,68 +264,50 @@ function formatSketchForStorageAll(sketchWithItems, user) {
sketchList = sketchList.map(sketch => formatSketchForStorage(sketch, user));
return sketchList;
return Promise.all(sketchList);
}
/**
* Get all the content for the relevant files in project.files[]
* @param {*} projectObject
* Fetch a list of all projects from the API
*/
async function fetchSketchContent(projectObject) {
const output = Object.assign({}, JSON.parse(JSON.stringify(projectObject)));
async function getProjectsList() {
const options = Object.assign({}, editorRequestOptions);
options.url = `${options.url}/sketches`;
const newFiles = output.files.map(async (item, i) => {
// if it is an html or js file
if (
(item.fileType === 'file' && item.name.endsWith('.html')) ||
item.name.endsWith('.js')
) {
const options = Object.assign({}, requestOptions);
options.url = `${item.url}`;
const results = await rp(options);
if (
options.url !== undefined ||
options.url !== null ||
options.url !== ''
) {
item.content = await rp(options);
// NOTE: remove the URL property if there's content
// Otherwise the p5 editor will try to pull from that url
if (item.content !== null) delete item.url;
}
return item;
// if it is NOT an html or js file
}
if (item.url) {
const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${
item.url.split(branchName)[1]
}`;
item.content = cdnRef;
item.url = cdnRef;
}
return item;
});
output.files = await Q.all(newFiles);
return output;
return results.sketches;
}
/**
* STEP 5
* Get all the content for the relevant files in project.files[] for all sketches
* @param {*} formattedSketchList
* Delete a project
*/
async function fetchSketchContentAll(formattedSketchList) {
let output = formattedSketchList.slice(0);
async function deleteProject(project) {
const options = Object.assign({}, editorRequestOptions);
options.method = 'DELETE';
options.url = `${options.url}/sketches/${project.id}`;
output = output.map(async item => fetchSketchContent(item));
const results = await rp(options);
output = await Q.all(output);
return results;
}
return output;
/**
* Create a new project
*/
async function createProject(project) {
try {
const options = Object.assign({}, editorRequestOptions);
options.method = 'POST';
options.url = `${options.url}/sketches`;
options.body = project;
const results = await rp(options);
return results;
} catch (err) {
throw err;
}
}
/**
@ -342,43 +317,34 @@ async function fetchSketchContentAll(formattedSketchList) {
* @param {*} user
*/
async function createProjectsInP5User(filledProjectList, user) {
const userProjects = await Project.find({ user: user._id });
const removeProjects = userProjects.map(async project => Project.remove({ _id: project._id }));
await Q.all(removeProjects);
console.log('deleted old projects!');
console.log('Finding existing projects...');
const newProjects = filledProjectList.map(async (project) => {
const item = new Project(project);
console.log(`saving ${project.name}`);
await item.save();
});
await Q.all(newProjects);
console.log(`Projects saved to User: ${editorUsername}!`);
}
const existingProjects = await getProjectsList();
/**
* STEP 0
* CHECK if user exists, ifnot create one
*
*/
async function checkP5User() {
const user = await User.findOne({ username: editorUsername });
console.log(`Will delete ${existingProjects.length} projects`);
if (!user) {
const ml5user = new User({
username: editorUsername,
email: process.env.ML5_EXAMPLES_EMAIL,
password: process.env.ML5_EXAMPLES_PASS
});
await ml5user.save((saveErr) => {
if (saveErr) throw saveErr;
console.log(`Created a user p5${ml5user}`);
try {
await Q.all(existingProjects.map(deleteProject));
console.log('deleted old projects!');
} catch (error) {
console.log('Problem deleting projects');
console.log(error);
process.exit(1);
}
try {
const newProjects = filledProjectList.map(async (project) => {
console.log(`saving ${project.name}`);
await createProject(project);
});
await Q.all(newProjects);
console.log(`Projects saved to User: ${editorUsername}!`);
} catch (error) {
console.log('Error saving projects');
console.log(error);
}
}
/**
* ---------------------------------------------------------
* --------------------- main ------------------------------
@ -394,18 +360,14 @@ async function checkP5User() {
* Delete existing and save
*/
async function make() {
await checkP5User();
// Get the user
const user = await User.findOne({
username: editorUsername
});
// Get the categories and their examples
const categories = await getCategories();
const categoryExamples = await getCategoryExamples(categories);
const examplesWithResourceTree = await traverseSketchTreeAll(categoryExamples);
const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user);
const filledProjectList = await fetchSketchContentAll(formattedSketchList);
await createProjectsInP5User(filledProjectList, user);
const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree);
await createProjectsInP5User(formattedSketchList);
console.log('done!');
process.exit();
}
@ -418,20 +380,14 @@ async function make() {
* Format the sketch files to be save to the db
* Delete existing and save
*/
// eslint-disable-next-line no-unused-vars
async function test() {
await checkP5User();
// Get the user
const user = await User.findOne({
username: editorUsername
});
// read from file while testing
const examplesWithResourceTree = JSON.parse(fs.readFileSync('./ml5-examplesWithResourceTree.json'));
const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user);
const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree);
const filledProjectList = await fetchSketchContentAll(formattedSketchList);
await createProjectsInP5User(filledProjectList, user);
await createProjectsInP5User(formattedSketchList);
console.log('done!');
process.exit();
}

View File

@ -16,6 +16,7 @@ import webpackHotMiddleware from 'webpack-hot-middleware';
import config from '../webpack/config.dev';
// Import all required modules
import api from './routes/api.routes';
import users from './routes/user.routes';
import sessions from './routes/session.routes';
import projects from './routes/project.routes';
@ -51,7 +52,7 @@ const corsOriginsWhitelist = [
// Run Webpack dev server in development mode
if (process.env.NODE_ENV === 'development') {
const compiler = webpack(config);
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config[0].output.publicPath }));
app.use(webpackDevMiddleware(compiler, { lazy: false, noInfo: true, publicPath: config[0].output.publicPath }));
app.use(webpackHotMiddleware(compiler));
corsOriginsWhitelist.push(/localhost/);
@ -95,6 +96,7 @@ app.use(session({
app.use(passport.initialize());
app.use(passport.session());
app.use('/api/v1', requestsOfTypeJSON(), api);
app.use('/api', requestsOfTypeJSON(), users);
app.use('/api', requestsOfTypeJSON(), sessions);
app.use('/api', requestsOfTypeJSON(), files);

View File

@ -0,0 +1,16 @@
/**
* Creates an increasing numeric ID
* for testing
*/
let nextId = 0;
export default function mockCreateId() {
const id = nextId;
nextId += 1;
return String(id);
}
export function resetMockCreateId() {
nextId = 0;
}

View File

@ -0,0 +1,33 @@
/**
* This is the base class for custom errors in
* the application.
*/
export class ApplicationError extends Error {
constructor(message, extra = {}) {
super();
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
this.name = 'ApplicationError';
this.message = message;
this.code = extra.code;
}
}
/**
* Create a new custom error class e.g.
* const UserNotFoundError = createApplicationErrorClass('UserNotFoundError');
*
* // Later
* throw new UserNotFoundError(`user ${user.name} not found`);
*
*/
export default function createApplicationErrorClass(name) {
return class extends ApplicationError {
constructor(...params) {
super(...params);
this.name = name;
}
};
}

8
server/utils/createId.js Normal file
View File

@ -0,0 +1,8 @@
import objectID from 'bson-objectid';
/**
* Creates a mongo ID
*/
export default function createId() {
return objectID().toHexString();
}