var express = require("express") , passport = require("passport") , passportLocal = require("passport-local") , fs = require("fs") , path = require("path") , proxy = require("./util/simple-http-proxy") , CSV = require("ya-csv") , CouchDB = require('./util/couch').CouchDB , _ = require("underscore") , tv4 = require("tv4") , HTTPResult = require("./util/http-result") , etags = require("./util/etags") , cryptoken = require('./util/crypto-token') ; function assertSetting(name, settings, validate) { if ( typeof settings[name] === 'undefined' ) { throw new Error("Required setting '"+name+"' undefined."); } if ( _.isFunction(validate) && !validate(settings[name]) ) { throw new Error("Setting '"+name+"' with value '"+settings[name]+"' is invalid."); } } exports.App = function(settings) { assertSetting("couchServerURL", settings, _.isString); assertSetting("dbName", settings, _.isString); var couch = new CouchDB(settings.couchServerURL,settings.dbName); var schema = require("./config/couchdb-schema.json"); return couch.get("schemaInfo").then(function(schemaInfo){ if (schemaInfo.version !== schema.version) { var msg = "Database has version "+schemaInfo.version+" but we expect version "+schema.version; throw new Error(msg); } return configureApp(settings,couch,schema); }); }; function configureApp(settings,couch,schema) { function clientPath(relativePath) { return path.resolve(__dirname+'/../client/'+relativePath); } passport.use(new passportLocal.Strategy(function(username, password, done){ if ( username === "igor" && password === "mayer" ) { done(null,{ username: "igor" }); } else { done(null,false,{ message: 'Invalid credentials.' }); } })); passport.serializeUser(function(user, done) { done(null, user.username); }); passport.deserializeUser(function(id, done) { done(null, {username: id}); }); var app = express(); app.use(express.logger()); app.use(express.compress()); app.use(express.favicon()); // cookies and session app.use(express.cookieParser()); app.use(express.session({ secret: "quasi experimental design" })); app.use('/api',express.bodyParser()); // passport app.use(passport.initialize()); app.use(passport.session()); // various middlewares function ensureAuthenticated(req,res,next){ if (!req.user) { return res.send(401,{error:"Login before accessing API."}); } else { return next(); } } function returnUser(req,res) { res.send(200, req.user); } function notImplemented(req,res) { res.send(501,{error:"API not implemented yet."}); } // static resources app.get('/', function(request, response){ response.sendfile(clientPath('index.html')); }); app.get('/*.html', function(request, response) { response.sendfile(clientPath(request.path)); }); _.each(['/dojo', '/dijit', '/dojox', '/qed', '/qed-client'], function(dir){ app.use(dir, express.static(clientPath(dir))); }); // post to this url to login app.post( '/api/login', passport.authenticate('local'), returnUser); // return the info for the current logged in user app.get( '/api/login', ensureAuthenticated, returnUser); // explicitly logout this user app.post( '/api/logout', ensureAuthenticated, function(req,res){ req.logout(); res.send(200,{}); }); var JSON_MIME = 'application/json'; var CSV_MIME = 'application/json'; function ensureMIME(mimeType) { return function(req,res,next) { if (!req.accepts(mimeType)) { res.send(406); } else { res.set({ 'Content-Type': mimeType }); next(); } }; } function stripAndReturnPrivates(obj) { var priv = {}; _.each(obj||{},function(val,key){ if (key.substring(0,1) === '_') { priv[key] = val; delete obj[key]; } }); return priv; } function identity(obj) { return obj; } function handleUnknownResponse(status,error){ return new HTTPResult(500,{error: error.reason}); } function handleUnknownError(error){ return new HTTPResult(500, {error: "Unknown error", innerError: error}); } function handleRowValues(result) { return _.map(result.rows, function(item) { return item.value; }); } function handleRowDocs(result) { return _.map(result.rows, function(item) { return item.doc; }); } function getDocumentsOfType (type) { var url = '_design/default/_view/by_type?key='+JSON.stringify(type); return HTTPResult.fromResponsePromise(couch.get(url).response, handleUnknownError) .handle({ 200: handleRowValues, 404: function() { return {error: "Cannot find collection of type "+type}; }, default: handleUnknownResponse }); } function getDocument(id,rev,type) { var opts = {headers:{}}; if (rev) { opts.headers['If-Non-Match'] = '"'+rev+'"'; } return HTTPResult.fromResponsePromise(couch.get(id,opts).response, handleUnknownError) .handle({ 200: function(doc){ if ( doc.type !== type ) { return new HTTPResult(404,{error:"Document not found."}); } else { var priv = stripAndReturnPrivates(doc); if ( priv._rev !== rev ) { doc._id = priv._id; doc._rev = priv._rev; return doc; } else { return new HTTPResult(304); } } }, 304: identity, default: handleUnknownResponse }); } function putDocument(id,rev,type,doc) { var priv = stripAndReturnPrivates(doc); if ( doc.type === type && tv4.validateResult(doc, schema) ) { var opts = rev ? {headers:{'If-Match':'"'+rev+'"'}} : {}; return HTTPResult.fromResponsePromise(couch.put(id,doc,opts).response, handleUnknownError) .handle({ 201: function(res){ doc._id = res.id; doc._rev = res.rev; return doc; }, 409: function(error) { return {error: error.reason}; }, default: handleUnknownResponse }); } else { return new HTTPResult(400,{error: "Document failed schema verification."}); } } function deleteDocument(id,rev) { if ( rev ) { var opts = {headers:{'If-Match':'"'+rev+'"'}}; return HTTPResult.fromResponsePromise(couch.delete(id,opts).response, handleUnknownError) .handle({ 200: identity, 409: function(error) { return {error: error.reason}; }, default: handleUnknownResponse }); } else { return new HTTPResult(409, {error: "Cannot identify document revision to delete."}); } } function postDocument(type,doc) { var priv = stripAndReturnPrivates(doc); if ( doc.type === type && tv4.validateResult(doc, schema) ) { return HTTPResult.fromResponsePromise(couch.post(doc).response, handleUnknownError) .handle({ 201: function(response) { doc._id = response.id; doc._rev = response.rev; return doc; }, default: handleUnknownResponse }); } else { return new HTTPResult(400,{error: "Document failed schema verification."}); } } function makeDocsGet(type) { return function(req,res) { getDocumentsOfType(type) .handle(res.send.bind(res)); }; } function makeDocGet_id(type) { return function(req,res) { var id = req.params.id; var rev = etags.parse(req.header('If-Non-Match'))[0]; getDocument(id,rev,type) .handle({ 200: function(doc){ res.set({ 'ETag': etags.format([doc._rev]) }).send(200, doc); }, default: res.send.bind(res) }); }; } function makeDocPut_id(type) { return function(req,res) { var id = req.params.id; var doc = req.body; var rev = etags.parse(req.header('If-Match'))[0] || (doc && doc._rev); putDocument(id,rev,type,doc) .handle({ 201: function(doc) { res.set({ 'ETag': etags.format([doc._rev]) }).send(201, doc); }, default: res.send.bind(res) }); }; } function makeDocDel_id(type) { return function(req,res) { var id = req.params.id; var doc = req.body; var rev = etags.parse(req.header('If-Match'))[0] || (doc && doc._rev); deleteDocument(id,rev) .handle(res.send.bind(res)); }; } function makeDocPost(type) { return function(req,res) { var doc = req.body; postDocument(type,doc) .handle({ 201: function(doc) { res.set({ 'ETag': etags.format([doc._rev]) }).send(201, doc); }, default: res.send.bind(res) }); }; } // Questions function getQuestionsWithCode(code) { var url = '_design/questions/_view/by_code'; var query = {include_docs:true,key:code}; return HTTPResult.fromResponsePromise(couch.get(url,{query:query}).response, handleUnknownError) .handle({ 200: handleRowDocs, default: handleUnknownResponse }); } function getQuestionsWithTopic(topic) { var url = '_design/questions/_view/all_topics'; var query = {reduce:false,include_docs:true,key:topic}; return HTTPResult.fromResponsePromise(couch.get(url,{query:query}).response, handleUnknownError) .handle({ 200: handleRowDocs, default: handleUnknownResponse }); } function getQuestionsWithCategoryAndTopic(category,topic) { var hasTopic = typeof topic !== 'undefined'; var url = '_design/questions/_view/all'; var query = {reduce:false,include_docs:true, startkey:hasTopic?[category,topic]:[category], endkey:hasTopic?[category,topic]:[category,{}]}; return HTTPResult.fromResponsePromise(couch.get(url,{query:query}).response, handleUnknownError) .handle({ 200: handleRowDocs, default: handleUnknownResponse }); } app.get('/api/questions', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res) { var hr; if ( 'category' in req.query ) { hr = getQuestionsWithCategoryAndTopic(req.query.category,req.query.topic); } else if ( 'topic' in req.query ) { hr = getQuestionsWithTopic(req.query.topic); } else if ( 'code' in req.query ) { hr = getQuestionsWithCode(req.query.code); } hr.handle(res.send.bind(res)); }); app.post('/api/questions', ensureAuthenticated, ensureMIME(JSON_MIME), function (req,res) { var doc = req.body; getQuestionsWithCode(doc.code) .handle({ 200: function(others) { if ( others.length > 0 ) { return new HTTPResult(403,{error:"Other question with this code already exists."}); } else { return postDocument('Question',doc); } } }) .handle(res.send.bind(res)); }); app.get('/api/questions/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocGet_id('Question')); app.put('/api/questions/:id', ensureAuthenticated, ensureMIME(JSON_MIME), function (req,res) { var id = req.params.id; var doc = req.body; var rev = etags.parse(req.header('If-Match'))[0] || (doc && doc._rev); getQuestionsWithCode(doc.code) .handle({ 200: function(others) { if ( others.length > 0 && _.some(others,function(other){ return other._id !== id; }) ) { return new HTTPResult(403,{error:"Other question with this code already exists."}); } else { return putDocument(id,rev,'Question',doc); } } }) .handle(res.send.bind(res)); }); app.delete('/api/questions/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocDel_id('Question')); // Categories and topics app.get('/api/categories', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res) { var url = '_design/questions/_view/all'; HTTPResult.fromResponsePromise(couch.get(url,{query:{reduce:true,group:true,group_level:1}}).response, handleUnknownError) .handle({ 200: function(result) { return _.map(result.rows, function(item) { return { name:item.key[0], count:item.value }; }); }, default: handleUnknownResponse }) .handle(res.send.bind(res)); }); app.get('/api/categories/:category/topics', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res) { var category = req.params.category; var url = '_design/questions/_view/all'; HTTPResult.fromResponsePromise(couch.get(url,{query:{reduce:true,group:true,group_level:2,startkey:[category],endkey:[category,{}]}}).response, handleUnknownError) .handle({ 200: function(result) { return _.map(result.rows, function(item) { return { name:item.key[1], count:item.value }; }); }, default: handleUnknownResponse }) .handle(res.send.bind(res)); }); app.get('/api/topics', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res) { var url = '_design/questions/_view/all_topics'; HTTPResult.fromResponsePromise(couch.get(url,{query:{reduce:true,group:true}}).response, handleUnknownError) .handle({ 200: function(result) { return _.map(result.rows, function(item) { return {name:item.key, count:item.value}; }); }, default: handleUnknownResponse }) .handle(res.send.bind(res)); }); // Surveys app.get('/api/surveys', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res) { var url; if ( 'drafts' in req.query ) { url = '_design/surveys/_view/drafts'; } else if ( 'published' in req.query ) { url = '_design/surveys/_view/published'; } else { url = '_design/default/_view/by_type?key='+JSON.stringify('Survey'); } HTTPResult.fromResponsePromise(couch.get(url).response, handleUnknownError) .handle({ 200: function(result) { return _.map(result.rows, function(item) { return item.value; }); }, default: handleUnknownResponse }) .handle(res.send.bind(res)); }); app.post('/api/surveys', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocPost('Survey')); app.get('/api/surveys/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocGet_id('Survey')); app.put('/api/surveys/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocPut_id('Survey')); app.delete('/api/surveys/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocDel_id('Survey')); // SurveyRuns app.get('/api/surveyRuns', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocsGet('SurveyRun')); app.post('/api/surveyRuns', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res) { var doc = req.body; randomToken() .handle({ 201: function(token) { doc.secret = token; return postDocument('SurveyRun',doc); } }) .handle(res.send.bind(res)); }); app.get('/api/surveyRuns/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocGet_id('SurveyRun')); app.put('/api/surveyRuns/:id', ensureAuthenticated, ensureMIME(JSON_MIME), function (req,res) { var id = req.params.id; var doc = req.body; var rev = etags.parse(req.header('If-Match'))[0] || (doc && doc._rev); var hr; if ( typeof doc.secret === 'undefined' ) { hr = randomToken() .handle({ 201: function(token) { doc.secret = token; return putDocument(id,rev,'SurveyRun',doc); } }); } else { hr = putDocument(id,rev,'SurveyRun',doc); } hr.handle(res.send.bind(res)); }); app.delete('/api/surveyRuns/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocDel_id('SurveyRun')); app.get('/api/surveyRuns/:id/responses', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res) { var id = req.params.id; getResponsesBySurveyRunId(id) .handle(res.send.bind(res)); }); app.get('/api/surveyRuns/:id/responses.csv', ensureAuthenticated, ensureMIME(CSV_MIME), function(req, res) { var id = req.params.id; getResponsesBySurveyRunId(id) .handle({ 200: function(responses) { var flatResponses = responsesToVariables(responses); res.set({ 'Content-Disposition': 'attachment; filename=surveyRun-'+id+'-responses.csv' }); res.status(200); writeObjectsToCSVStream(flatResponses, res); res.end(); }, default: res.send.bind(res) }); }); // Responses function getResponsesBySurveyRunId(surveyRunId) { var url = '_design/responses/_view/by_surveyrun?key='+JSON.stringify(surveyRunId); return HTTPResult.fromResponsePromise(couch.get(url).response, handleUnknownError) .handle({ 200: handleRowValues, default: handleUnknownResponse }); } app.get('/api/responses', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res){ var hr; if ( 'surveyRunId' in req.query ) { hr = getResponsesBySurveyRunId(req.query.surveyRunId); } else { hr = getDocumentsOfType('Response'); } hr.handle(res.send.bind(res)); }); app.get('/api/responses/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocGet_id('Response')); app.post('/api/responses', ensureAuthenticated, ensureMIME(JSON_MIME), function (req,res) { var doc = req.body; randomToken() .handle({ 201: function(token) { doc.secret = token; return postDocument('Response',doc); } }) .handle(res.send.bind(res)); }); app.put('/api/responses/:id', ensureAuthenticated, ensureMIME(JSON_MIME), function (req,res) { var id = req.params.id; var doc = req.body; var rev = etags.parse(req.header('If-Match'))[0] || (doc && doc._rev); var hr; if ( typeof doc.secret === 'undefined' ) { hr = randomToken() .handle({ 201: function(token) { doc.secret = token; return putDocument(id,rev,'Response',doc); } }); } else { hr = putDocument(id,rev,'Response',doc); } hr.handle(res.send.bind(res)); }); app.delete('/api/responses/:id', ensureAuthenticated, ensureMIME(JSON_MIME), makeDocDel_id('Response')); //respondents api function isSurveyRunRunning(surveyRun) { var now = new Date(); var afterStart = !surveyRun.startDate || now >= new Date(surveyRun.startDate); var beforeEnd = !surveyRun.endDate || now <= new Date(surveyRun.endDate); return afterStart && beforeEnd; } app.get('/api/open/responses/:id', ensureMIME(JSON_MIME), function(req,res) { var id = req.params.id; var rev = etags.parse(req.header('If-Non-Match'))[0]; var secret = req.query.secret; getDocument(id,rev,'Response') .handle({ 200: function(response) { if ( response.secret === secret ) { return getDocument(response.surveyRunId,[],'SurveyRun') .handle({ 200: function(surveyRun) { if ( !isSurveyRunRunning(surveyRun) ) { return new HTTPResult(404,{error:"Survey is not running anymore."}); } else { response._surveyRun = surveyRun; return response; } } }); } else { return new HTTPResult(403,{error: "Wrong secret for response"}); } } }) .handle(res.send.bind(res)); }); app.post('/api/open/responses', ensureMIME(JSON_MIME), function(req,res) { var secret = req.query.secret; var response = req.body; delete response._surveyRun; getDocument(response.surveyRunId,[],'SurveyRun') .handle({ 200: function(surveyRun) { if ( surveyRun.secret !== secret ) { return new HTTPResult(403,{error:"Wrong secret for surveyRun."}); } else if ( !isSurveyRunRunning(surveyRun) ) { return new HTTPResult(404,{error:"Survey is not running anymore."}); } else if ( surveyRun.mode === 'closed' ) { return new HTTPResult(403,{error:"Survey is closed and doesn't allow new responses."}); } else { return randomToken() .handle({ 201: function(token) { response.secret = token; return postDocument('Response',response) .handle({ 201: function(doc){ doc._surveyRun = surveyRun; return doc; } }); } }); } } }) .handle(res.send.bind(res)); }); app.put('/api/open/responses/:id', ensureMIME(JSON_MIME), function(req,res){ var id = req.params.id; var doc = req.body; var rev = etags.parse(req.header('If-Match'))[0] || (doc && doc._rev); var secret = req.query.secret || doc.secret; delete doc._surveyRun; getDocument(id,[],'Response') .handle({ 200: function(prev) { if ( prev.secret !== secret ) { return new HTTPResult(403,{error: "Secrets are not the same."}); } else if ( prev.surveyRunId !== doc.surveyRunId ) { return new HTTPResult(400,{error:"Response cannot change it's surveyRunId."}); } else { doc.secret = secret; // restore in case it got lost or was changed return getDocument(doc.surveyRunId,[],'SurveyRun') .handle({ 200: function(surveyRun) { if ( !isSurveyRunRunning(surveyRun) ) { return new HTTPResult(404,{error:"Survey is not running anymore."}); } else { return putDocument(id,rev,'Response',doc) .handle({ 200: function(doc) { doc._surveyRun = surveyRun; return new HTTPResult(201,doc); } }); } } }); } } }) .handle(res.send.bind(res)); }); app.delete('/api/open/responses/:id', ensureMIME(JSON_MIME), function(req,res){ var id = req.params.id; var doc = req.body; var rev = etags.parse(req.header('If-Match'))[0] || (doc && doc._rev); var secret = req.query.secret || doc.secret; delete doc._surveyRun; getDocument(id,[],'Response') .handle({ 200: function(prev) { if ( prev.secret !== secret ) { return new HTTPResult(403,{error: "Secrets are not the same."}); } else { return deleteDocument(id,rev,doc); } } }) .handle(res.send.bind(res)); }); // uuids app.get('/api/uuids', ensureAuthenticated, ensureMIME(JSON_MIME), function(req,res){ var count = (req.query.count && parseInt(req.query.count,10)) || 1; HTTPResult.fromResponsePromise(couch.uuids({query:{count:count}}).response, handleUnknownError) .handle({ 200: function(res) { return res.uuids; }, default: handleUnknownResponse }) .handle(res.send.bind(res)); }); return app; } function responsesToVariables(responses) { return _.map(responses, responseToVariables); } function responseToVariables(response) { var result = flattenObject(response.answers); return result; } function flattenObject(value) { var result = {}; (function agg(val,key,res){ if ( _.isObject(val) ) { var keys = _.keys(val); // FIXME : dirty hack for questions with only one input if ( keys.length === 1 ) { agg(val[keys[0]],key,res); } else { _.forEach(val, function(v,k){ agg(v,(key ? key+'.' : '')+k,res); }); } } else if ( _.isArray(val) ) { // FIXME : dirty hack for questions with only one input if ( val.length === 1 ) { agg(val[0],key,res); } else { _.forEach(val, function(v,i){ agg(v,(key ? key+'.' : '')+i,res); }); } } else { res[key] = val; } })(value,null,result); return result; } function writeObjectsToCSVStream(objects, stream, prelude) { var keys = _.chain(objects) .map(_.keys) .flatten() .uniq() .value(); var idxs = {}; _.forEach(keys, function(key,idx){ idxs[key] = idx; }); var writer = new CSV.CsvWriter(stream); if ( prelude ) { _.forEach(prelude, function(val,key){ writer.writeRecord([key,val]); }); } writer.writeRecord(keys); _.forEach(objects, function(obj){ var row = []; _.forEach(obj, function(val,key){ row[idxs[key]] = val; }); writer.writeRecord(row); }); } function randomToken() { var result = new HTTPResult(); cryptoken(8) .then(function(token){ result.set(201,token); }, function(ex){ result.set(500,{error:"Cannot generate secrets.", innerError: ex}); }); return result; }