Line | Hits | Source |
---|---|---|
1 | 1 | exports.app = require('./app'); |
2 | 1 | exports.models = require('./models'); |
3 | 1 | exports.api = require('./api'); |
4 | 1 | exports.website = require('./website'); |
5 | 1 | exports.filters = require('./filters'); |
6 | 1 | exports.documentation = require('./documentation'); |
Line | Hits | Source |
---|---|---|
1 | 1 | var models = require('./models'); |
2 | ||
3 | 1 | function handleMongooseError(err, res, next) { |
4 | 3 | if (err.name == "ValidationError") { |
5 | 1 | res.send(422, makeConciseValidationError(err)); |
6 | 2 | } else if (err.name == "CastError") { |
7 | 1 | res.send(422, {message: err.message}); |
8 | } else { | |
9 | 1 | next(err); |
10 | } | |
11 | } | |
12 | ||
13 | 1 | function makeConciseValidationError(err) { |
14 | 1 | var errors = {}; |
15 | 1 | Object.keys(err.errors).forEach(function(error) { |
16 | 1 | errors[error] = err.errors[error].message; |
17 | }); | |
18 | 1 | return { |
19 | message: "Validation Error", | |
20 | errors: errors | |
21 | }; | |
22 | } | |
23 | ||
24 | 1 | exports.getSubmission = function(req, res, next) { |
25 | 1 | res.send(res.locals.submission); |
26 | }; | |
27 | ||
28 | 1 | exports.listSubmissions = function(req, res, next) { |
29 | 2 | if (!req.query.learner) |
30 | 1 | return res.send(422, {"message": "invalid search query"}); |
31 | ||
32 | 1 | models.Submission.find({ |
33 | learner: req.query.learner | |
34 | }, function(err, submissions) { | |
35 | 1 | if (err) return next(err); |
36 | 1 | return res.send(submissions); |
37 | }); | |
38 | }; | |
39 | ||
40 | 1 | exports.listMentors = function(req, res, next) { |
41 | 1 | models.Mentor.find(function(err, mentors) { |
42 | 1 | if (err) return next(err); |
43 | ||
44 | 1 | var json = mentors.map(function(mentor) { |
45 | 1 | return { |
46 | email: mentor.email, | |
47 | classifications: mentor.classifications | |
48 | }; | |
49 | }); | |
50 | ||
51 | 1 | res.send(json); |
52 | }); | |
53 | }; | |
54 | ||
55 | 1 | exports.changeMentor = function(req, res, next) { |
56 | 3 | var email = req.body.email; |
57 | 3 | var classifications = req.body.classifications; |
58 | ||
59 | 3 | if (typeof(email) != "string") |
60 | 1 | return res.send(422, {message: "invalid email"}); |
61 | ||
62 | 2 | if (!classifications || !classifications.length) |
63 | 1 | models.Mentor.remove({email: email}, function(err) { |
64 | 1 | if (err) return handleMongooseError(err, res, next); |
65 | 1 | res.send(200, {message: "deleted"}); |
66 | }); | |
67 | else | |
68 | 1 | models.Mentor.update({email: email}, { |
69 | email: email, | |
70 | classifications: classifications | |
71 | }, { | |
72 | upsert: true | |
73 | }, function(err) { | |
74 | 1 | if (err) return handleMongooseError(err, res, next); |
75 | 1 | res.send(200, {message: "updated"}); |
76 | }); | |
77 | }; | |
78 | ||
79 | 1 | exports.submit = function(req, res, next) { |
80 | 4 | var submission = new models.Submission(req.body); |
81 | 4 | submission.save(function(err, submission) { |
82 | 7 | if (err) return handleMongooseError(err, res, next); |
83 | 1 | return res.send(201, { |
84 | id: submission._id.toString() | |
85 | }); | |
86 | }); | |
87 | }; |
Line | Hits | Source |
---|---|---|
1 | 1 | var path = require('path'); |
2 | 1 | var url = require('url'); |
3 | 1 | var fs = require('fs'); |
4 | 1 | var _ = require('underscore'); |
5 | 1 | var express = require('express'); |
6 | 1 | var nunjucks = require('nunjucks'); |
7 | 1 | var flash = require('connect-flash'); |
8 | 1 | var clientSessions = require('client-sessions'); |
9 | ||
10 | 1 | var api = require('./api'); |
11 | 1 | var website = require('./website'); |
12 | 1 | var filters = require('./filters'); |
13 | 1 | var paths = require('./paths'); |
14 | ||
15 | 1 | const PERSONA_JS_URL = "https://login.persona.org/include.js"; |
16 | ||
17 | 1 | function securityHeaders(options) { |
18 | 8 | return function(req, res, next) { |
19 | 60 | res.set('X-Frame-Options', 'DENY'); |
20 | 60 | res.set('X-Content-Type-Options', 'nosniff'); |
21 | 60 | if (options.enableHSTS) |
22 | 1 | res.set('Strict-Transport-Security', |
23 | 'max-age=31536000; includeSubDomains'); | |
24 | ||
25 | 60 | addContentSecurityPolicy(req, res); |
26 | 60 | next(); |
27 | }; | |
28 | } | |
29 | ||
30 | 1 | function addContentSecurityPolicy(req, res) { |
31 | 60 | var policies = { |
32 | 'default-src': ["'self'"], | |
33 | 'script-src': [ | |
34 | "'self'", | |
35 | "https://login.persona.org" | |
36 | ], | |
37 | 'frame-src': ['https://login.persona.org'], | |
38 | 'style-src': ["'self'", "'unsafe-inline'"], | |
39 | 'img-src': ['*'], | |
40 | // options is deprecated, but Firefox still needs it. | |
41 | 'options': [] | |
42 | }; | |
43 | 60 | if (req.path == '/test/') { |
44 | // Some of our testing tools, e.g. sinon, use eval(), so we'll | |
45 | // enable it for this one endpoint. | |
46 | 4 | policies['script-src'].push("'unsafe-eval'"); |
47 | 4 | policies['options'].push('eval-script'); |
48 | } | |
49 | 60 | var directives = []; |
50 | 60 | Object.keys(policies).forEach(function(directive) { |
51 | 360 | directives.push(directive + ' ' + policies[directive].join(' ')); |
52 | }); | |
53 | 60 | var policy = directives.join('; '); |
54 | 60 | res.set('Content-Security-Policy', policy); |
55 | 60 | res.set('X-Content-Security-Policy', policy); |
56 | 60 | res.set('X-WebKit-CSP', policy); |
57 | } | |
58 | ||
59 | 1 | function applyTheme(themeDir, app, loaders) { |
60 | 1 | themeDir = path.resolve(__dirname, "..", themeDir); |
61 | ||
62 | 1 | var appLocalsFile = path.join(themeDir, 'app-locals.json'); |
63 | 1 | var viewsDir = path.join(themeDir, 'views'); |
64 | 1 | var staticDir = path.join(themeDir, 'static'); |
65 | ||
66 | 1 | loaders.push(new nunjucks.FileSystemLoader(viewsDir)); |
67 | 1 | app.use('/theme/', express.static(staticDir)); |
68 | ||
69 | 1 | if (fs.existsSync(appLocalsFile)) |
70 | 1 | _.extend(app.locals, JSON.parse(fs.readFileSync(appLocalsFile, 'utf8'))); |
71 | ||
72 | 1 | app.locals.THEME_ROOT = '/theme/'; |
73 | 1 | }; |
74 | ||
75 | 1 | exports.build = function(options) { |
76 | 8 | var app = express(); |
77 | ||
78 | 8 | app.configure(function() { |
79 | 8 | var env; |
80 | 8 | var loaders = []; |
81 | 8 | var personaJsUrl = options.personaIncludeJs || PERSONA_JS_URL; |
82 | 8 | var definePersonaRoutes = options.personaDefineRoutes || |
83 | require('express-persona'); | |
84 | 8 | var csrf = express.csrf(); |
85 | 8 | var apiAuth = options.apiKey |
86 | ? express.basicAuth('api', options.apiKey) | |
87 | : function(req, res, next) { | |
88 | 1 | res.send(403, 'API access is disabled.'); |
89 | }; | |
90 | ||
91 | 8 | app.use(securityHeaders({ |
92 | enableHSTS: url.parse(options.personaAudience).protocol == "https:" | |
93 | })); | |
94 | 8 | app.use(express.static(paths.staticDir)); |
95 | 8 | if (options.debug) |
96 | 2 | app.use('/test', express.static(paths.staticTestDir)); |
97 | ||
98 | 8 | _.extend(app.locals, { |
99 | DOT_MIN: options.debug ? '' : '.min', | |
100 | PERSONA_JS_URL: personaJsUrl, | |
101 | APP_NAME: 'Aestimia' | |
102 | }); | |
103 | ||
104 | 8 | if (options.themeDir) |
105 | 1 | applyTheme(options.themeDir, app, loaders); |
106 | ||
107 | 8 | loaders.push(new nunjucks.FileSystemLoader(paths.viewsDir)); |
108 | 8 | env = new nunjucks.Environment(loaders, { |
109 | autoescape: true | |
110 | }); | |
111 | ||
112 | 8 | env.express(app); |
113 | 8 | Object.keys(filters).forEach(function(name) { |
114 | 8 | env.addFilter(name, filters[name]); |
115 | }); | |
116 | 8 | app.nunjucksEnv = env; |
117 | ||
118 | 8 | app.use(function(req, res, next) { |
119 | 49 | res.header("Cache-Control", "no-cache"); |
120 | 49 | next(); |
121 | }); | |
122 | 8 | app.use(express.bodyParser()); |
123 | 8 | app.use(clientSessions({ |
124 | cookieName: 'session', | |
125 | secret: options.cookieSecret, | |
126 | duration: 24 * 60 * 60 * 1000, // defaults to 1 day | |
127 | })); | |
128 | ||
129 | 9 | if (options.defineExtraMiddleware) options.defineExtraMiddleware(app); |
130 | ||
131 | 8 | app.use(function CsrfOrApiAuth(req, res, next) { |
132 | 49 | if (req.path.match(/^\/api\//)) { |
133 | 13 | req.isApi = true; |
134 | 13 | return apiAuth(req, res, next); |
135 | } else | |
136 | 36 | return csrf(req, res, next); |
137 | }); | |
138 | 8 | app.use(flash()); |
139 | 8 | app.use(website.setResponseLocalsForTemplates); |
140 | ||
141 | 8 | definePersonaRoutes(app, {audience: options.personaAudience}); |
142 | }); | |
143 | ||
144 | 8 | app.param('submissionId', website.findSubmissionById); |
145 | ||
146 | 8 | app.get('/', website.index); |
147 | 8 | app.get('/history', website.history); |
148 | 8 | app.get('/submissions/:submissionId', website.submissionDetail); |
149 | 8 | app.post('/submissions/:submissionId', website.submitAssessment); |
150 | 8 | app.get('/demo', website.demo); |
151 | 8 | app.get('/docs', website.docs); |
152 | ||
153 | 8 | app.get('/api/submissions/:submissionId', api.getSubmission); |
154 | 8 | app.get('/api/submissions', api.listSubmissions); |
155 | 8 | app.post('/api/submission', api.submit); |
156 | 8 | app.get('/api/mentors', api.listMentors); |
157 | 8 | app.post('/api/mentor', api.changeMentor); |
158 | ||
159 | 12 | if (options.defineExtraRoutes) options.defineExtraRoutes(app); |
160 | ||
161 | 8 | app.use(function(err, req, res, next) { |
162 | 2 | if (typeof(err.status) == 'number') |
163 | 1 | return res.type('text/plain').send(err.status, err.message); |
164 | 1 | process.stderr.write(err.stack); |
165 | 1 | res.send(500, 'Sorry, something exploded!'); |
166 | }); | |
167 | ||
168 | 8 | return app; |
169 | }; |
Line | Hits | Source |
---|---|---|
1 | 1 | var fs = require('fs'); |
2 | 1 | var path = require('path'); |
3 | 1 | var cheerio = require('cheerio'); |
4 | 1 | var nunjucks = require('nunjucks'); |
5 | ||
6 | 1 | var data = require('../test/data'); |
7 | 1 | var paths = require('./paths'); |
8 | ||
9 | // This is synchronous, which is bad, but it's only called from | |
10 | // the API documentation pages, which will only get hit by | |
11 | // developers. | |
12 | 1 | exports.parseRawApiSections = function parseRawApiSections() { |
13 | 4 | var docs = fs.readFileSync(paths.fromRoot('doc', 'api-raw.html'), |
14 | 'utf8'); | |
15 | 4 | var $ = cheerio.load(docs); |
16 | 4 | var sections = []; |
17 | ||
18 | 4 | $("section").each(function() { |
19 | 28 | var title = $(this).find("h2").remove(); |
20 | 28 | var template = new nunjucks.Template($(this).html()); |
21 | ||
22 | 28 | sections.push({ |
23 | title: title.text(), | |
24 | id: $(this).attr('id'), | |
25 | html: template.render({examples: data.docExamples}) | |
26 | }); | |
27 | }); | |
28 | ||
29 | 4 | return sections; |
30 | }; | |
31 | ||
32 | 1 | function copyAndRewrite(query, srcAttr, outdir) { |
33 | 5 | var src = query.attr(srcAttr); |
34 | 5 | var fullPath = path.join(paths.staticDir, src); |
35 | 5 | var filename = src.split('/').slice(-1)[0]; |
36 | 5 | var content = fs.readFileSync(fullPath); |
37 | ||
38 | 5 | fs.writeFileSync(path.join(outdir, filename), content); |
39 | 5 | query.attr(srcAttr, filename); |
40 | } | |
41 | ||
42 | 1 | exports.generateStaticDocs = function(outdir, commitHash) { |
43 | 1 | var loaders = [ |
44 | new nunjucks.FileSystemLoader(paths.fromRoot('doc')), | |
45 | new nunjucks.FileSystemLoader(paths.viewsDir) | |
46 | ]; | |
47 | 1 | var env = new nunjucks.Environment(loaders, {autoescape: true}); |
48 | 1 | var content = env.render('docs.html', { |
49 | sections: exports.parseRawApiSections(), | |
50 | generatingStaticDocs: true, | |
51 | commitHash: commitHash | |
52 | }); | |
53 | 1 | var $ = cheerio.load(content); |
54 | ||
55 | 2 | if (!fs.existsSync(outdir)) fs.mkdirSync(outdir); |
56 | ||
57 | 1 | $('script').each(function() { |
58 | 3 | copyAndRewrite($(this), "src", outdir); |
59 | }); | |
60 | 1 | $('link[rel="stylesheet"]').each(function() { |
61 | 2 | copyAndRewrite($(this), "href", outdir); |
62 | }); | |
63 | 1 | fs.writeFileSync(path.join(outdir, "index.html"), $.html()); |
64 | }; |
Line | Hits | Source |
---|---|---|
1 | 1 | var _ = require('underscore'); |
2 | 1 | var SafeString = require('nunjucks/src/runtime').SafeString; |
3 | ||
4 | 1 | exports.timeago = function(date) { |
5 | 24 | var html = '<time class="timeago" datetime="' + |
6 | _.escape(date.toISOString()) + | |
7 | '">' + _.escape(date.toUTCString()) + '</time>'; | |
8 | 24 | return new SafeString(html); |
9 | }; |
Line | Hits | Source |
---|---|---|
1 | 1 | exports.Mentor = require('./mentor'); |
2 | 1 | exports.Submission = require('./submission'); |
3 | 1 | exports.paginate = require('./paginate'); |
4 | 1 | exports.validators = require('./validators'); |
Line | Hits | Source |
---|---|---|
1 | 1 | var _ = require('underscore'); |
2 | 1 | var mongoose = require('mongoose'); |
3 | ||
4 | 1 | var validEmail = require('./validators').validEmail; |
5 | ||
6 | 1 | var mentorSchema = new mongoose.Schema({ |
7 | email: {type: String, required: true, unique: true, validate: validEmail}, | |
8 | classifications: [String] | |
9 | }); | |
10 | ||
11 | 1 | var Mentor = mongoose.model('Mentor', mentorSchema); |
12 | ||
13 | 1 | Mentor.classificationsFor = function classificationsFor(email, cb) { |
14 | 84 | Mentor.findOne({ |
15 | email: email | |
16 | }, function(err, mentor) { | |
17 | 84 | if (err) return cb(err); |
18 | 84 | var userClassifications = mentor ? mentor.classifications : []; |
19 | 84 | Mentor.findOne({ |
20 | email: '*@' + email.split('@')[1] | |
21 | }, function(err, mentor) { | |
22 | 84 | if (err) return cb(err); |
23 | 84 | var domainClassifications = mentor ? mentor.classifications : []; |
24 | 84 | cb(null, _.union(userClassifications, domainClassifications)); |
25 | }); | |
26 | }); | |
27 | }; | |
28 | ||
29 | 1 | module.exports = Mentor; |
Line | Hits | Source |
---|---|---|
1 | 1 | var assert = require('assert'); |
2 | ||
3 | 1 | var _ = require('underscore'); |
4 | 1 | var async = require('async'); |
5 | ||
6 | 1 | var Model = require('mongoose').Model; |
7 | ||
8 | 1 | Model.paginate = function(options, cb) { |
9 | 11 | options.page = parseInt(options.page); |
10 | 11 | options.resultsPerPage = parseInt(options.resultsPerPage); |
11 | ||
12 | 11 | assert(options.page > 0, "page must be positive"); |
13 | 11 | assert(options.resultsPerPage > 0, "resultsPerPage must be positive"); |
14 | ||
15 | 11 | var model = this; |
16 | 11 | var criteria = options.criteria; |
17 | 11 | var resultsPerPage = options.resultsPerPage; |
18 | 11 | var queryOptions = _.extend({}, options.options, { |
19 | skip: (options.page-1) * resultsPerPage, | |
20 | limit: resultsPerPage | |
21 | }); | |
22 | ||
23 | 11 | async.waterfall([ |
24 | function(done) { | |
25 | 11 | model.find(criteria).setOptions(queryOptions).exec(done) |
26 | }, | |
27 | function(results, done) { | |
28 | 11 | model.count(criteria).exec(function(err, count) { |
29 | 11 | var totalPages = Math.ceil(count / resultsPerPage); |
30 | 11 | done(err, results, totalPages); |
31 | }); | |
32 | } | |
33 | ], cb); | |
34 | }; | |
35 | ||
36 | 1 | module.exports = Model.paginate; |
Line | Hits | Source |
---|---|---|
1 | 1 | var _ = require('underscore'); |
2 | 1 | var async = require('async'); |
3 | 1 | var mongoose = require('mongoose'); |
4 | ||
5 | 1 | var validators = require('./validators'); |
6 | 1 | var validEmail = validators.validEmail; |
7 | 1 | var safeUrl = validators.safeUrl; |
8 | 1 | var validMediaType = validators.validMediaType; |
9 | 1 | var Mentor = require('./mentor'); |
10 | ||
11 | 1 | var reviewSchema = new mongoose.Schema({ |
12 | date: {type: Date, default: Date.now}, | |
13 | author: {type: String, required: true, validate: validEmail}, | |
14 | response: {type: String, required: true}, | |
15 | satisfiedRubrics: [Number] | |
16 | }); | |
17 | ||
18 | 1 | var submissionSchema = new mongoose.Schema({ |
19 | learner: {type: String, required: true, validate: validEmail}, | |
20 | criteriaUrl: {type: String, required: true, validate: safeUrl}, | |
21 | achievement: { | |
22 | name: String, | |
23 | description: String, | |
24 | imageUrl: {type: String, validate: safeUrl} | |
25 | }, | |
26 | cannedResponses: [String], | |
27 | // TODO: Ensure that at least one non-empty classification exists. | |
28 | classifications: [String], | |
29 | evidence: [ | |
30 | { | |
31 | url: {type: String, validate: safeUrl}, | |
32 | mediaType: {type: String, default: "link", validate: validMediaType}, | |
33 | reflection: String | |
34 | } | |
35 | ], | |
36 | flagged: {type: Boolean, default: false}, | |
37 | onChangeUrl: {type: String, validate: safeUrl}, | |
38 | creationDate: {type: Date, default: Date.now}, | |
39 | assignedTo: { | |
40 | mentor: {type: String, validate: validEmail}, | |
41 | expiry: Date | |
42 | }, | |
43 | // TODO: Ensure that at least one rubric is required. | |
44 | rubric: { | |
45 | items: [ | |
46 | { | |
47 | required: Boolean, | |
48 | text: String | |
49 | } | |
50 | ] | |
51 | }, | |
52 | reviews: [reviewSchema], | |
53 | }); | |
54 | ||
55 | 1 | reviewSchema.pre('save', function(next) { |
56 | 65 | var parent = this.ownerDocument(); |
57 | 65 | var err; |
58 | ||
59 | 65 | if (parent.cannedResponses.length) { |
60 | 2 | if (parent.cannedResponses.indexOf(this.response) == -1) { |
61 | 1 | err = new Error("review response is not in list of canned responses"); |
62 | 1 | err.name = "ValidationError"; |
63 | 1 | err.errors = {response: {message: err.message}}; |
64 | 1 | return next(err); |
65 | } | |
66 | } | |
67 | 64 | next(null); |
68 | }); | |
69 | ||
70 | 1 | reviewSchema.pre('save', function(next) { |
71 | 64 | var parent = this.ownerDocument(); |
72 | 64 | var self = this; |
73 | ||
74 | 64 | Mentor.classificationsFor(self.author, function(err, classifications) { |
75 | 64 | if (err) |
76 | 1 | return next(err); |
77 | 63 | if (_.intersection(classifications, parent.classifications).length) |
78 | 62 | return next(); |
79 | 1 | err = new Error("reviewer " + self.author + " does not have " + |
80 | "permission to review"); | |
81 | 1 | err.name = "ValidationError"; |
82 | 1 | err.errors = {reviewer: {message: err.message}}; |
83 | 1 | next(err); |
84 | }); | |
85 | }); | |
86 | ||
87 | // Atomically assign the given mentor w/ the given expiry time to the | |
88 | // submission. | |
89 | // | |
90 | // Calls cb with (err, submission). If the submission is already assigned | |
91 | // to someone, submission will be null. | |
92 | // | |
93 | // The document this method is called on becomes invalidated immediately | |
94 | // after use; if you need to keep doing things with it, use the submission | |
95 | // object passed to the callback. | |
96 | 1 | submissionSchema.methods.assignTo = function(email, expiry, cb) { |
97 | 8 | var criteria = {_id: this._id}; |
98 | 8 | if (this.assignedTo.mentor != email && |
99 | this.assignedTo.expiry && | |
100 | this.assignedTo.expiry.getTime() > Date.now()) | |
101 | 2 | return cb(null, null); |
102 | 6 | if (this.assignedTo.mentor) |
103 | 2 | criteria.assignedTo = { |
104 | mentor: this.assignedTo.mentor, | |
105 | expiry: this.assignedTo.expiry | |
106 | }; | |
107 | 6 | Submission.findOneAndUpdate(criteria, { |
108 | assignedTo: { | |
109 | mentor: email, | |
110 | expiry: expiry | |
111 | } | |
112 | }, function(err, submission) { | |
113 | 6 | if (err) return cb(err); |
114 | 6 | cb(null, submission); |
115 | }); | |
116 | }; | |
117 | ||
118 | 1 | submissionSchema.methods.getAssignee = function() { |
119 | 19 | if (!this.assignedTo.expiry || |
120 | this.assignedTo.expiry.getTime() <= Date.now()) | |
121 | 10 | return null; |
122 | ||
123 | 9 | return this.assignedTo.mentor; |
124 | }; | |
125 | ||
126 | 1 | submissionSchema.methods.latestReview = function() { |
127 | 8 | return this.reviews[this.reviews.length-1]; |
128 | }; | |
129 | ||
130 | 1 | submissionSchema.methods.isReviewed = function() { |
131 | 45 | return !!this.reviews.length || this.flagged; |
132 | }; | |
133 | ||
134 | 1 | submissionSchema.methods.isAwarded = function() { |
135 | 8 | if (!this.reviews.length) |
136 | 1 | return false; |
137 | 7 | var satisfied = this.latestReview().satisfiedRubrics; |
138 | 7 | var result = true; |
139 | 7 | this.rubric.items.forEach(function(item, i) { |
140 | 28 | if (item.required && satisfied.indexOf(i) == -1) |
141 | 4 | result = false; |
142 | }, this); | |
143 | 7 | return result; |
144 | }; | |
145 | ||
146 | 1 | submissionSchema.methods.canBeReviewedBy = function(email, cb) { |
147 | 11 | var self = this; |
148 | ||
149 | 11 | async.waterfall([ |
150 | Mentor.classificationsFor.bind(Mentor, email), | |
151 | function(classifications, done) { | |
152 | 11 | if (_.intersection(classifications, self.classifications).length) |
153 | 10 | return done(null, true); |
154 | 1 | return done(null, false); |
155 | } | |
156 | ], cb); | |
157 | }; | |
158 | ||
159 | 1 | submissionSchema.methods.isLearnerUnderage = function() { |
160 | 23 | return !!this.cannedResponses.length; |
161 | }; | |
162 | ||
163 | 1 | var Submission = mongoose.model('Submission', submissionSchema); |
164 | ||
165 | 1 | function findSubmissions(options, cb) { |
166 | 7 | async.waterfall([ |
167 | Mentor.classificationsFor.bind(Mentor, options.email), | |
168 | function(classifications, done) { | |
169 | 7 | options.criteria.classifications = {$in: classifications}; |
170 | 7 | Submission.paginate({ |
171 | criteria: options.criteria, | |
172 | options: options.options, | |
173 | page: options.page, | |
174 | resultsPerPage: options.resultsPerPage | |
175 | }, done); | |
176 | } | |
177 | ], cb); | |
178 | } | |
179 | ||
180 | 1 | Submission.findReviewed = function(options, cb) { |
181 | 2 | findSubmissions({ |
182 | criteria: { | |
183 | $or: [ | |
184 | {reviews: {$not: {$size: 0}}}, | |
185 | {flagged: true} | |
186 | ] | |
187 | }, | |
188 | options: {sort: '-creationDate'}, | |
189 | email: options.email, | |
190 | page: options.page, | |
191 | resultsPerPage: options.resultsPerPage | |
192 | }, cb); | |
193 | }; | |
194 | ||
195 | 1 | Submission.findForReview = function(options, cb) { |
196 | 5 | findSubmissions({ |
197 | criteria: { | |
198 | $and: [ | |
199 | {reviews: {$size: 0}}, | |
200 | {flagged: false} | |
201 | ] | |
202 | }, | |
203 | options: {sort: 'creationDate'}, | |
204 | email: options.email, | |
205 | page: options.page, | |
206 | resultsPerPage: options.resultsPerPage | |
207 | }, cb); | |
208 | }; | |
209 | ||
210 | 1 | module.exports = Submission; |
211 | ||
212 | // This is only here so tests can mock/test things out. | |
213 | 1 | Submission.Mentor = Mentor; |
Line | Hits | Source |
---|---|---|
1 | 1 | exports.validEmail = [ |
2 | function(email) { | |
3 | // Apparently using a regexp to do anything more strict than this | |
4 | // is hard: http://stackoverflow.com/a/201378 | |
5 | 393 | return /^([^@]+)@([^@]+)$/.test(email); |
6 | }, | |
7 | "email must be of form user@host" | |
8 | ]; | |
9 | ||
10 | 1049 | exports.safeUrl = [function(url) { return /^https?:\/\//.test(url); }, |
11 | "url must be http or https"]; | |
12 | ||
13 | 1 | exports.validMediaType = [ |
14 | function(value) { | |
15 | 701 | return ['image', 'link'].indexOf(value) != -1; |
16 | }, | |
17 | 'invalid media type' | |
18 | ]; |
Line | Hits | Source |
---|---|---|
1 | 1 | var path = require('path'); |
2 | ||
3 | 1 | var fromRoot = exports.fromRoot = function fromRoot() { |
4 | 8 | var fullPath = path.join.apply(this, arguments); |
5 | 8 | return path.resolve(__dirname + '/..', fullPath); |
6 | }; | |
7 | ||
8 | 1 | exports.viewsDir = fromRoot('views'); |
9 | 1 | exports.staticDir = fromRoot('static'); |
10 | 1 | exports.staticTestDir = fromRoot('test', 'browser'); |
Line | Hits | Source |
---|---|---|
1 | 1 | var request = require('request'); |
2 | ||
3 | 1 | var Submission = require('./models').Submission; |
4 | 1 | var demoData = require('../test/data'); |
5 | 1 | var docs = require('./documentation'); |
6 | ||
7 | 1 | const RESULTS_PER_PAGE = 10; |
8 | 1 | const ASSIGNMENT_LOCKOUT_MS = exports.ASSIGNMENT_LOCKOUT_MS = 1000 * 60 * 15; |
9 | ||
10 | 1 | function callWebhook(submission) { |
11 | 5 | if (submission.onChangeUrl) { |
12 | 2 | request.post({ |
13 | url: submission.onChangeUrl, | |
14 | json: { | |
15 | _id: submission._id | |
16 | } | |
17 | }, function(err) { | |
18 | 2 | if (err) |
19 | 1 | console.error("calling webhook", submission.onChangeUrl, |
20 | "for submission", submission._id.toString(), | |
21 | "failed with error", err.message); | |
22 | }); | |
23 | } | |
24 | } | |
25 | ||
26 | 1 | exports.findSubmissionById = function(req, res, next, id) { |
27 | 16 | Submission.findOne({_id: id}, function(err, submission) { |
28 | 16 | function proceed() { |
29 | 11 | res.locals.submission = submission; |
30 | 11 | next(); |
31 | } | |
32 | ||
33 | 16 | if (err) { |
34 | 2 | if (err.name == "CastError") |
35 | 1 | return res.send(404); |
36 | 1 | return next(err); |
37 | } | |
38 | 14 | if (!submission) |
39 | 1 | return res.send(404); |
40 | 13 | if (req.isApi) |
41 | 1 | return proceed(); |
42 | 12 | if (!req.session.email) |
43 | 1 | return res.status(401).render('access-denied.html'); |
44 | 11 | submission.canBeReviewedBy(req.session.email, function(err, result) { |
45 | 11 | if (err) return next(err); |
46 | 11 | if (result) |
47 | 10 | return proceed(); |
48 | 1 | return res.status(403).render('access-denied.html'); |
49 | }); | |
50 | }); | |
51 | }; | |
52 | ||
53 | 1 | exports.submissionDetail = function(req, res, next) { |
54 | 1 | res.render('submission-detail.html'); |
55 | }; | |
56 | ||
57 | 1 | function assignAssessment(req, res, next) { |
58 | 2 | var exp = Date.now() + ASSIGNMENT_LOCKOUT_MS; |
59 | 2 | res.locals.submission.assignTo(req.session.email, exp, function(err, sub) { |
60 | 2 | if (err) return next(err); |
61 | 2 | if (sub) { |
62 | 1 | return res.redirect(303, req.path + '#assess'); |
63 | } else { | |
64 | 1 | req.flash('error', 'Sorry, someone else is already assessing this.'); |
65 | 1 | return res.redirect(303, req.path); |
66 | } | |
67 | }); | |
68 | } | |
69 | ||
70 | 1 | function unassignAssessment(req, res, next) { |
71 | 1 | var exp = Date.now(); |
72 | 1 | res.locals.submission.assignTo(req.session.email, exp, function(err, sub) { |
73 | 1 | if (err) return next(err); |
74 | 1 | return res.redirect(303, req.path); |
75 | }); | |
76 | } | |
77 | ||
78 | 1 | exports.submitAssessment = function(req, res, next) { |
79 | 9 | function respond(flashType, flashMsg) { |
80 | 5 | return function(err) { |
81 | 5 | if (err) return next(err); |
82 | 5 | callWebhook(submission); |
83 | 5 | req.flash(flashType, flashMsg); |
84 | 5 | return res.redirect(303, req.path); |
85 | }; | |
86 | } | |
87 | ||
88 | 9 | var submission = res.locals.submission; |
89 | 9 | var satisfiedRubrics = []; |
90 | ||
91 | 9 | if (req.body['action'] == 'assign') |
92 | 2 | return assignAssessment(req, res, next); |
93 | ||
94 | 7 | if (req.body['action'] == 'unassign') |
95 | 1 | return unassignAssessment(req, res, next); |
96 | ||
97 | 6 | if (req.body['action'] == 'flag') { |
98 | 1 | return Submission.update({ |
99 | _id: submission._id | |
100 | }, { | |
101 | flagged: true | |
102 | }, respond('info', 'Assessment reported for inappropriate content.')); | |
103 | } | |
104 | ||
105 | 5 | if (req.body['action'] == 'unflag') { |
106 | 1 | return Submission.update({ |
107 | _id: submission._id | |
108 | }, { | |
109 | flagged: false | |
110 | }, respond('info', 'Assessment un-reported for inappropriate content.')); | |
111 | } | |
112 | ||
113 | 4 | req.body.response = req.body.response && req.body.response.trim(); |
114 | ||
115 | 4 | if (!req.body.response) { |
116 | 1 | req.flash('error', 'Please provide an assessment response.'); |
117 | 1 | return res.redirect(303, req.path); |
118 | } | |
119 | ||
120 | 3 | submission.rubric.items.forEach(function(item, i) { |
121 | 12 | if (req.body['rubric_' + i] == 'on') |
122 | 2 | satisfiedRubrics.push(i); |
123 | }); | |
124 | 3 | Submission.update({ |
125 | _id: submission._id | |
126 | }, { | |
127 | $push: { | |
128 | reviews: { | |
129 | author: req.session.email, | |
130 | response: req.body.response, | |
131 | satisfiedRubrics: satisfiedRubrics | |
132 | } | |
133 | } | |
134 | }, respond('success', 'Assessment submitted.')); | |
135 | }; | |
136 | ||
137 | 1 | exports.demo = function(req, res, next) { |
138 | 1 | var submissions = []; |
139 | 1 | Object.keys(demoData.submissions).forEach(function(name) { |
140 | 2 | submissions.push({ |
141 | name: name, | |
142 | json: JSON.stringify(demoData.submissions[name], null, 2) | |
143 | }); | |
144 | }); | |
145 | 1 | res.render('demo.html', { |
146 | submissions: submissions, | |
147 | sections: docs.parseRawApiSections() | |
148 | }); | |
149 | }; | |
150 | ||
151 | 1 | exports.docs = function(req, res) { |
152 | 1 | return res.render('docs.html', {sections: docs.parseRawApiSections()}); |
153 | }; | |
154 | ||
155 | 1 | var showPaginatedSubmissions = exports.showPaginatedSubmissions = |
156 | function showPaginatedSubmissions(methodName, view, req, res, next) { | |
157 | 17 | var page = parseInt(req.query.page); |
158 | 17 | var linkToPage = function(page) { |
159 | 8 | return req.path + '?page=' + page; |
160 | }; | |
161 | ||
162 | 31 | if (isNaN(page) || page <= 0) page = 1; |
163 | ||
164 | 17 | Submission[methodName]({ |
165 | email: req.session.email, | |
166 | page: page, | |
167 | resultsPerPage: RESULTS_PER_PAGE | |
168 | }, function(err, submissions, totalPages) { | |
169 | 14 | if (err) return next(err); |
170 | 12 | if (!submissions.length && totalPages) |
171 | 1 | return res.redirect(302, linkToPage(totalPages)); |
172 | 11 | res.render(view, { |
173 | submissions: submissions, | |
174 | page: page, | |
175 | totalPages: totalPages, | |
176 | prevPage: (page > 1) && linkToPage(page - 1), | |
177 | nextPage: (page < totalPages) && linkToPage(page + 1) | |
178 | }); | |
179 | }); | |
180 | } | |
181 | ||
182 | 1 | exports.history = function(req, res, next) { |
183 | 2 | if (req.session.email) { |
184 | 1 | showPaginatedSubmissions('findReviewed', 'history.html', req, res, next); |
185 | } else | |
186 | 1 | res.status(401).render('access-denied.html'); |
187 | }; | |
188 | ||
189 | 1 | exports.index = function(req, res, next) { |
190 | 11 | if (req.session.email) { |
191 | 4 | showPaginatedSubmissions('findForReview', 'queue.html', req, res, next); |
192 | } else | |
193 | 7 | res.render('splash.html'); |
194 | }; | |
195 | ||
196 | 1 | function makeGetFlashMessages(req) { |
197 | 46 | var cached = null; |
198 | ||
199 | 46 | return function() { |
200 | 20 | if (!cached) { |
201 | 19 | cached = []; |
202 | 19 | ['error', 'success', 'info'].forEach(function(category) { |
203 | 57 | req.flash(category).forEach(function(html) { |
204 | 1 | cached.push({ |
205 | category: category, | |
206 | html: html | |
207 | }); | |
208 | }); | |
209 | }); | |
210 | } | |
211 | 20 | return cached; |
212 | }; | |
213 | } | |
214 | ||
215 | 1 | exports.setResponseLocalsForTemplates = function(req, res, next) { |
216 | 46 | res.locals.csrfToken = req.session._csrf; |
217 | 46 | res.locals.email = req.session.email; |
218 | 46 | res.locals.messages = makeGetFlashMessages(req); |
219 | 46 | next(); |
220 | }; |