Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions api/controllers/cac.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,33 @@ exports.protectedOptions = function (args, res) {
res.status(200).send();
};

exports.publicCACSignUp = async function (args, res) {
const CACUser = mongoose.model('CACUser');
const Project = mongoose.model('Project');
const MAX_EMAIL_LENGTH = 254;
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// These routes are anonymous, so anything but a plain address string (an operator object such as
// {"$ne": ""}) must never reach a query. Returns the trimmed address, or null.
function cleanEmail(value) {
if (typeof value !== 'string') {
return null;
}
const email = value.trim();
return email.length <= MAX_EMAIL_LENGTH && EMAIL_SHAPE.test(email) ? email : null;
}

let cacObject = args.swagger.params.cac.value;
exports.publicCACSignUp = async function (args, res) {
const cacObject = args.swagger.params.cac.value;
const projectId = args.swagger.params.projId.value;
defaultLog.info('publicCACSignUp for project:', projectId);
if (!mongoose.Types.ObjectId.isValid(projectId)) {
const email = cleanEmail(cacObject && cacObject.email);
if (!Utils.isObjectIdString(projectId) || !email) {
return Actions.sendResponse(res, 400, {});
}
const CACUser = mongoose.model('CACUser');
const Project = mongoose.model('Project');
delete cacObject.read;
delete cacObject.write;

cacObject.email = email;
cacObject.project = projectId;

try {
Expand Down Expand Up @@ -60,20 +74,20 @@ exports.publicCACSignUp = async function (args, res) {
};

exports.publicCACRemoveMember = async function (args, res) {
const Project = mongoose.model('Project');
const CACUser = mongoose.model('CACUser');

const projId = args.swagger.params.projId.value;
defaultLog.info('publicCACRemoveMember for project:', projId);
if (!mongoose.Types.ObjectId.isValid(projId)) {
const cac = args.swagger.params.cac.value;
const email = cleanEmail(cac && cac.email);
if (!Utils.isObjectIdString(projId) || !email) {
return Actions.sendResponse(res, 400, {});
}
const cac = args.swagger.params.cac.value;
const Project = mongoose.model('Project');
const CACUser = mongoose.model('CACUser');

try {
const member = await CACUser.findOne({
_schemaName: 'CACUser',
email: cac.email,
email: email,
project: new mongoose.Types.ObjectId(projId)
});

Expand All @@ -85,10 +99,10 @@ exports.publicCACRemoveMember = async function (args, res) {
await CACUser.deleteOne({ _id: member._id });

Utils.recordAction('Delete', 'CACMemberFromProject', 'public', member._id);
defaultLog.info('Removed CAC member email:', cac.email, 'from project:', projId);
defaultLog.info('Removed CAC member %s from project %s', member._id, projId);
return Actions.sendResponse(res, 200, {});
} else {
defaultLog.info('CAC member not found with email:', cac.email, 'in project:', projId);
defaultLog.info('CAC member to remove not found in project %s', projId);
return Actions.sendResponse(res, 404, {});
}
} catch (e) {
Expand Down
65 changes: 0 additions & 65 deletions api/controllers/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ var Actions = require('../helpers/actions');
var Utils = require('../helpers/utils');
const demiPush = require('../helpers/demiPush');
const parentRead = require('../helpers/parentRead');
const { DateTime } = require('luxon');
const { COMMENT_FIELDS: ALLOWED_FIELDS, COMMENT_PUBLIC_FIELDS: PUBLIC_FIELDS, PUBLIC_ROLES } = require('../helpers/constants');

// A comment's own read[] stays Published when staff unpublish its period or project, so the
Expand Down Expand Up @@ -382,70 +381,6 @@ async function getNextCommentIdCount(period) {
return periodUpdated.commentIdCount;
}

// Create a new Comment
exports.unProtectedPost = async function (args, res) {
try {
const obj = args.swagger.params.comment.value;
defaultLog.info('Incoming new comment for period: %s', obj.period);
if (args.swagger.params.period && args.swagger.params.period.value && !mongoose.Types.ObjectId.isValid(args.swagger.params.period.value)) {
return Actions.sendResponse(res, 400, { });
}
const Comment = mongoose.model('Comment');

// Ensure the comment is received before the end date of the period.
const commentPeriodModel = mongoose.model('CommentPeriod');
const period = await commentPeriodModel.findOne({ _id: new mongoose.Types.ObjectId(obj.period) });

defaultLog.info('Period: %s', period && period._id);

if (DateTime.now() > DateTime.fromJSDate(new Date(period.dateCompleted))) {
// Past the specified time
const endDate = DateTime.fromJSDate(new Date(period.dateCompleted)).toFormat('MMM dd, yyyy h:mm a');
defaultLog.info('Comment was not received before completed date:', endDate);
return Actions.sendResponse(res,
400, {
code: '400',
message: 'Comment recieved after date completed'
}
);
}

// get the next commentID for this period
const commentIdCount = await getNextCommentIdCount(new mongoose.Types.ObjectId(obj.period));

defaultLog.info('Next comment id: %d', commentIdCount);

let cmt = new Comment();
cmt._schemaName = 'Comment';
cmt.eaoStatus = 'Pending';
cmt.author = obj.author;
cmt.comment = obj.comment;
cmt.dateAdded = new Date();
cmt.dateUpdated = new Date();
cmt.isAnonymous = obj.isAnonymous;
cmt.location = obj.location;
cmt.period = new mongoose.Types.ObjectId(obj.period);
cmt.commentId = commentIdCount;
cmt.documents = [];
cmt.datePosted = undefined;
cmt.submittedCAC = obj.submittedCAC;

cmt.read = ['staff', 'sysadmin'];
cmt.write = ['staff', 'sysadmin'];
cmt.delete = ['staff', 'sysadmin'];

defaultLog.debug('About to save comment for period: %s', obj.period);

const c = await cmt.save();
Utils.recordAction('Post', 'Comment', 'public', c._id);
defaultLog.info('Saved new comment object: %s', c._id);
return Actions.sendMirrored(res, 200, c, demiPush.comment(c));
} catch (e) {
defaultLog.error(`Error: ${e.message}`);
return Actions.sendResponse(res, 400, e);
}
};

// Update an existing Comment
exports.protectedPut = async function (args, res) {
var objId = args.swagger.params.commentId.value;
Expand Down
141 changes: 31 additions & 110 deletions api/controllers/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,107 +110,6 @@ exports.publicGet = async function (args, res,) {
}
};

exports.unProtectedPost = async function (args, res) {
defaultLog.info('Creating new public document object');
if (args.swagger.params._comment && args.swagger.params._comment.value && !mongoose.Types.ObjectId.isValid(args.swagger.params._comment.value)) {
return Actions.sendResponse(res, 400, { });
}
if (args.swagger.params.project && args.swagger.params.project.value && !mongoose.Types.ObjectId.isValid(args.swagger.params.project.value)) {
return Actions.sendResponse(res, 400, { });
}

var _comment = args.swagger.params._comment.value;
var project = args.swagger.params.project.value;
var upfile = args.swagger.params.upfile.value;

// Enforce file size limit of 10MB in RAM
const MAX_FILE_SIZE = 10 * 1024 * 1024;
if (upfile.size > MAX_FILE_SIZE) {
defaultLog.warn('File upload rejected: exceeds 10MB limit (%d bytes)', upfile.size);
return Actions.sendResponse(res, 400, { message: 'File size exceeds 10MB limit.' });
}

// Enforce MIME allow-list
const allowedMimeTypes = [
'application/pdf',
'image/png',
'image/jpeg',
'image/jpg',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
];
if (!allowedMimeTypes.includes(upfile.mimetype)) {
defaultLog.warn('File upload rejected: unallowed MIME type (%s)', upfile.mimetype);
return Actions.sendResponse(res, 400, { message: 'MIME type is not allowed.' });
}

var guid = crypto.randomUUID();
var ext = mime.extension(upfile.mimetype);
var tempFilePath = uploadDir + guid + '.' + ext;

try {
// Virus scan
if (ENABLE_VIRUS_SCANNING) {
var scanPassed = await Utils.avScan(upfile.buffer);
if (!scanPassed) {
defaultLog.warn('File failed virus check.');
return Actions.sendResponse(res, 400, { 'message': 'File failed virus check.' });
}
}

// Write to temp file and upload to MinIO
fs.writeFileSync(tempFilePath, upfile.buffer);
var minioFile = await MinioController.putDocument(
MinioController.BUCKETS.DOCUMENTS_BUCKET, project, upfile.originalname, tempFilePath
);
fs.unlinkSync(tempFilePath);

// Save document to MongoDB
var Document = mongoose.model('Document');
var doc = new Document();
doc.project = new mongoose.Types.ObjectId(project);
doc._comment = new mongoose.Types.ObjectId(_comment);
doc._addedBy = 'public';
doc._createdDate = new Date();
doc.read = ['sysadmin', 'staff'];
doc.write = ['sysadmin', 'staff'];
doc.delete = ['sysadmin', 'staff'];

doc.internalOriginalName = upfile.originalname;
doc.internalURL = minioFile.path;
doc.internalExt = minioFile.extension;
doc.internalSize = upfile.size;
doc.internalOriginalSha256 = sha256Of(upfile.buffer);
doc.passedAVCheck = true;
doc.internalMime = upfile.mimetype;

doc.documentSource = 'COMMENT';
doc.displayName = upfile.originalname;
doc.documentFileName = upfile.originalname;
doc.dateUploaded = new Date();
doc.datePosted = new Date();
doc.documentAuthor = args.body.documentAuthor;
doc.documentAuthorType = new mongoose.Types.ObjectId(args.body.documentAuthorType);

try {
var d = await doc.save();
defaultLog.info('Saved new document object:', d._id);
var Comment = mongoose.model('Comment');
await Comment.updateOne({ _id: _comment }, { $addToSet: { documents: d._id } });
Utils.recordAction('Post', 'Document', 'public', d._id);
return Actions.sendMirrored(res, 200, d, demiPush.document(d));
} catch (saveError) {
defaultLog.error('Document save failed, rolling back MinIO:', saveError);
MinioController.deleteDocument(MinioController.BUCKETS.DOCUMENTS_BUCKET, doc.project, doc.internalURL);
return Actions.sendResponse(res, 400, saveError);
}
} catch (e) {
defaultLog.error(`Error in unProtectedPost: ${e.message}`);
delete e['path'];
return Actions.sendResponse(res, 400, e);
}
};

exports.protectedHead = function (args, res) {
defaultLog.debug('Roles:', args.swagger.params.auth_payload.realm_access.roles);

Expand Down Expand Up @@ -575,15 +474,23 @@ exports.protectedOpen = function (args, res) {
};

// Create a new document
// The admin upload form sends the string "null" for these when they are empty.
const NULLABLE_ID_FIELDS = ['milestone', 'type', 'documentAuthorType', 'projectPhase'];

exports.protectedPost = async function (args, res) {
defaultLog.info('Creating new protected document object');
var project = args.swagger.params.project.value;
var _comment = args.swagger.params._comment.value;

if (args.swagger.params._comment && args.swagger.params._comment.value && !mongoose.Types.ObjectId.isValid(_comment)) {
return Actions.sendResponse(res, 400, { });
}
if (args.swagger.params.project && args.swagger.params.project.value && !mongoose.Types.ObjectId.isValid(project)) {
const badId = ['project', '_comment', ...NULLABLE_ID_FIELDS].find(name => {
const value = args.swagger.params[name] && args.swagger.params[name].value;
if (!value || (value === 'null' && NULLABLE_ID_FIELDS.includes(name))) {
return false;
}
return !Utils.isObjectIdString(value);
});
if (badId) {
defaultLog.warn('Protected file upload rejected: %s is not an id', badId);
return Actions.sendResponse(res, 400, { });
}

Expand Down Expand Up @@ -622,10 +529,14 @@ exports.protectedPost = async function (args, res) {

// Write to temp file and upload to MinIO
fs.writeFileSync(tempFilePath, upfile.buffer);
var minioFile = await MinioController.putDocument(
MinioController.BUCKETS.DOCUMENTS_BUCKET, project, args.swagger.params.documentFileName.value, tempFilePath
);
fs.unlinkSync(tempFilePath);
let minioFile;
try {
minioFile = await MinioController.putDocument(
MinioController.BUCKETS.DOCUMENTS_BUCKET, project, args.swagger.params.documentFileName.value, tempFilePath
);
} finally {
fs.unlinkSync(tempFilePath);
}

// Build document model
var Document = mongoose.model('Document');
Expand Down Expand Up @@ -689,7 +600,17 @@ exports.protectedPost = async function (args, res) {
return Actions.sendMirrored(res, 200, d, demiPush.document(d));
} catch (saveError) {
defaultLog.error('Document save failed, rolling back MinIO:', saveError);
MinioController.deleteDocument(MinioController.BUCKETS.DOCUMENTS_BUCKET, doc.project, doc.internalURL);
try {
// A save can report failure after the write landed (a write timeout); then the file is in use.
if (await Document.exists({ _id: doc._id })) {
defaultLog.warn('Document %s was stored despite the save error; keeping its file', doc._id);
} else {
// internalURL already starts with the project, so it is the whole object key.
await MinioController.deleteObject(MinioController.BUCKETS.DOCUMENTS_BUCKET, doc.internalURL);
}
} catch (rollbackError) {
defaultLog.error('MinIO rollback of %s failed: %s', doc.internalURL, rollbackError);
}
return Actions.sendResponse(res, 400, saveError);
}
} catch (e) {
Expand Down
18 changes: 10 additions & 8 deletions api/helpers/minio.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,19 @@ exports.putFileDirect = putFileDirect;
* @param fileName the name of the file
*/
var deleteDocument = function (bucket, projectCode, fileName) {
if(isMockService()) return Promise.resolve(() => { return {}; });
return minioClient.removeObject(bucket, projectCode + '/' + fileName)
.then(function (result, err) {
if (err) {
throw err;
}
return result;
});
return deleteObject(bucket, projectCode + '/' + fileName);
};
exports.deleteDocument = deleteDocument;

/**
* Delete a file from Minio by its full object key, which is what a document's internalURL holds.
*/
const deleteObject = function (bucket, objectName) {
if(isMockService()) return Promise.resolve();
return minioClient.removeObject(bucket, objectName);
};
exports.deleteObject = deleteObject;

/**
* Get a Minio presigned url, for a specific file object, that permits GET operations.
* The url can be used multiple times, but expires after 5 minutes.
Expand Down
8 changes: 8 additions & 0 deletions api/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ exports.getValidObjectId = function (param) {
return new mongoose.Types.ObjectId(val);
};

/**
* True only for a 24-hex-digit string. ObjectId.isValid also passes any 12-character string and
* some objects, which lets a request body steer a query.
*/
exports.isObjectIdString = function (value) {
return typeof value === 'string' && /^[0-9a-f]{24}$/i.test(value);
};

exports.getBasePath = function (protocol, host) {
return protocol + '://' + host;
};
Expand Down
Loading
Loading