Преглед изворни кода

Merge branch 'master' into node-4.2

Henry Oswald пре 10 година
родитељ
комит
23cb6a9419
100 измењених фајлова са 5988 додато и 696 уклоњено
  1. 125 42
      services/web/Gruntfile.coffee
  2. 16 0
      services/web/README.md
  3. 9 4
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  4. 14 2
      services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
  5. 39 0
      services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
  6. 31 0
      services/web/app/coffee/Features/BetaProgram/BetaProgramHandler.coffee
  7. 3 1
      services/web/app/coffee/Features/Blog/BlogController.coffee
  8. 19 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
  9. 68 0
      services/web/app/coffee/Features/Compile/ClsiFormatChecker.coffee
  10. 39 22
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  11. 82 22
      services/web/app/coffee/Features/Compile/CompileController.coffee
  12. 14 6
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  13. 4 0
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  14. 2 2
      services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
  15. 19 8
      services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
  16. 9 6
      services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
  17. 2 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  18. 5 3
      services/web/app/coffee/Features/Project/ProjectDeleter.coffee
  19. 5 0
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  20. 4 2
      services/web/app/coffee/Features/References/ReferencesController.coffee
  21. 2 0
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  22. 32 2
      services/web/app/coffee/Features/StaticPages/UniversityController.coffee
  23. 2 2
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  24. 236 34
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  25. 4 3
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  26. 2 1
      services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
  27. 11 8
      services/web/app/coffee/Features/User/UserController.coffee
  28. 10 2
      services/web/app/coffee/Features/User/UserCreator.coffee
  29. 1 1
      services/web/app/coffee/Features/User/UserHandler.coffee
  30. 2 0
      services/web/app/coffee/Features/User/UserPagesController.coffee
  31. 2 1
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  32. 126 0
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  33. 0 81
      services/web/app/coffee/Features/Wiki/WikiController.coffee
  34. 63 7
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  35. 10 7
      services/web/app/coffee/infrastructure/Server.coffee
  36. 1 0
      services/web/app/coffee/models/User.coffee
  37. 40 12
      services/web/app/coffee/router.coffee
  38. 18 15
      services/web/app/views/admin/index.jade
  39. 47 0
      services/web/app/views/beta_program/opt_in.jade
  40. 14 2
      services/web/app/views/contact-us-modal.jade
  41. 2 2
      services/web/app/views/general/500.jade
  42. 83 13
      services/web/app/views/layout.jade
  43. 3 4
      services/web/app/views/layout/footer.jade
  44. 5 1
      services/web/app/views/layout/navbar.jade
  45. 10 6
      services/web/app/views/project/editor.jade
  46. 1 1
      services/web/app/views/project/editor/editor.jade
  47. 1 1
      services/web/app/views/project/editor/left-menu.jade
  48. 133 7
      services/web/app/views/project/editor/pdf.jade
  49. 1 1
      services/web/app/views/project/editor/share.jade
  50. 5 16
      services/web/app/views/project/list/side-bar.jade
  51. 1 1
      services/web/app/views/sentry.jade
  52. 17 13
      services/web/app/views/subscriptions/dashboard.jade
  53. 1 1
      services/web/app/views/subscriptions/edit-billing-details.jade
  54. 2 2
      services/web/app/views/subscriptions/successful_subscription.jade
  55. 1 1
      services/web/app/views/translations/translation_message.jade
  56. 19 0
      services/web/app/views/university/case_study.jade
  57. 11 0
      services/web/app/views/user/settings.jade
  58. 0 68
      services/web/app/views/wiki/page.jade
  59. 18 6
      services/web/config/settings.defaults.coffee
  60. 3853 0
      services/web/npm-shrinkwrap.json
  61. 12 7
      services/web/package.json
  62. 12 1
      services/web/public/coffee/ide.coffee
  63. 4 2
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  64. 2 0
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  65. 3 1
      services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
  66. 20 0
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
  67. 91 0
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
  68. 150 38
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  69. 12 66
      services/web/public/coffee/ide/pdfng/directives/pdfJs.coffee
  70. 2 2
      services/web/public/coffee/ide/pdfng/directives/pdfPage.coffee
  71. 126 78
      services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
  72. 5 4
      services/web/public/coffee/ide/pdfng/directives/pdfSpinner.coffee
  73. 41 20
      services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
  74. 25 1
      services/web/public/coffee/ide/settings/services/settings.coffee
  75. 3 1
      services/web/public/coffee/ide/share/controllers/ShareController.coffee
  76. 2 2
      services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
  77. 1 0
      services/web/public/coffee/libs.coffee
  78. 3 1
      services/web/public/coffee/main/account-upgrade.coffee
  79. 29 3
      services/web/public/coffee/main/contact-us.coffee
  80. 46 2
      services/web/public/coffee/main/event.coffee
  81. 3 9
      services/web/public/coffee/main/learn.coffee
  82. 19 1
      services/web/public/coffee/main/new-subscription.coffee
  83. 3 4
      services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee
  84. 3 9
      services/web/public/coffee/main/templates.coffee
  85. 14 0
      services/web/public/coffee/services/algolia-search.coffee
  86. 58 0
      services/web/public/coffee/services/log-hints-feedback.coffee
  87. BIN
      services/web/public/img/about/brian_gough.jpg
  88. BIN
      services/web/public/img/about/geri.jpg
  89. BIN
      services/web/public/img/about/henry_oswald.jpg
  90. BIN
      services/web/public/img/about/james_allen.jpg
  91. BIN
      services/web/public/img/about/kiri_channon.jpg
  92. BIN
      services/web/public/img/about/michael.jpg
  93. BIN
      services/web/public/img/about/paulo_reis.jpg
  94. BIN
      services/web/public/img/about/shane_kilkelly.jpg
  95. BIN
      services/web/public/img/crests/cambridge.png
  96. BIN
      services/web/public/img/crests/durham.png
  97. BIN
      services/web/public/img/crests/icl.png
  98. BIN
      services/web/public/img/crests/liverpool.jpg
  99. BIN
      services/web/public/img/crests/mit.gif
  100. BIN
      services/web/public/img/crests/nasa.png

+ 125 - 42
services/web/Gruntfile.coffee

@@ -7,18 +7,69 @@ module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-mocha-test'
 	grunt.loadNpmTasks 'grunt-mocha-test'
 	grunt.loadNpmTasks 'grunt-available-tasks'
 	grunt.loadNpmTasks 'grunt-available-tasks'
 	grunt.loadNpmTasks 'grunt-contrib-requirejs'
 	grunt.loadNpmTasks 'grunt-contrib-requirejs'
-	grunt.loadNpmTasks 'grunt-execute'
 	grunt.loadNpmTasks 'grunt-bunyan'
 	grunt.loadNpmTasks 'grunt-bunyan'
 	grunt.loadNpmTasks 'grunt-sed'
 	grunt.loadNpmTasks 'grunt-sed'
 	grunt.loadNpmTasks 'grunt-git-rev-parse'
 	grunt.loadNpmTasks 'grunt-git-rev-parse'
 	grunt.loadNpmTasks 'grunt-file-append'
 	grunt.loadNpmTasks 'grunt-file-append'
 	grunt.loadNpmTasks 'grunt-file-append'
 	grunt.loadNpmTasks 'grunt-file-append'
 	grunt.loadNpmTasks 'grunt-env'
 	grunt.loadNpmTasks 'grunt-env'
+	grunt.loadNpmTasks 'grunt-newer'
+	grunt.loadNpmTasks 'grunt-contrib-watch'
+	grunt.loadNpmTasks 'grunt-parallel'
+	grunt.loadNpmTasks 'grunt-exec'
+	# grunt.loadNpmTasks 'grunt-contrib-imagemin'
+	# grunt.loadNpmTasks 'grunt-sprity'
 
 
 	config =
 	config =
-		execute:
-			app:
-				src: "app.js"
+
+		exec:
+			run:
+				command:"node app.js | ./node_modules/logger-sharelatex/node_modules/bunyan/bin/bunyan --color"
+			cssmin:
+				command:"node_modules/clean-css/bin/cleancss --s0 -o public/stylesheets/style.css public/stylesheets/style.css"
+
+
+		watch:
+			coffee:
+				files: 'public/**/*.coffee'
+				tasks: ['quickcompile:coffee']
+				options: {}
+
+			less:
+				files: '**/*.less'
+				tasks: ['compile:css']
+				options: {}
+
+
+		parallel:
+			run:
+				tasks:['exec', 'watch']
+				options:
+					grunt:true
+					stream:true
+
+
+		# imagemin:
+		# 	dynamic:                       
+		# 		files: [{
+		# 			expand: true
+		# 			cwd: 'public/img/'
+		# 			src: ['**/*.{png,jpg,gif}']
+		# 			dest: 'public/img/'
+		# 		}]
+		# 	options:
+		# 		interlaced:false
+		# 		optimizationLevel: 7
+
+		# sprity:
+		# 	sprite:
+		# 		options:
+		# 			cssPath:"/img/"
+		# 			'style': '../../public/stylesheets/app/sprites.less'
+		# 			margin: 0
+		# 		src: ['./public/img/flags/24/*.png']
+		# 		dest: './public/img/sprite'
+
 
 
 		coffee:
 		coffee:
 			app_dir: 
 			app_dir: 
@@ -84,11 +135,16 @@ module.exports = (grunt) ->
 				files:
 				files:
 					"public/stylesheets/style.css": "public/stylesheets/style.less"
 					"public/stylesheets/style.css": "public/stylesheets/style.less"
 
 
+
+
+
 		env:
 		env:
 			run:
 			run:
 				add: 
 				add: 
 					NODE_TLS_REJECT_UNAUTHORIZED:0
 					NODE_TLS_REJECT_UNAUTHORIZED:0
 
 
+
+
 		requirejs:
 		requirejs:
 			compile:
 			compile:
 				options:
 				options:
@@ -170,44 +226,47 @@ module.exports = (grunt) ->
 				pattern: "@@RELEASE@@"
 				pattern: "@@RELEASE@@"
 				replacement: process.env.BUILD_NUMBER || "(unknown build)"
 				replacement: process.env.BUILD_NUMBER || "(unknown build)"
 
 
+
+			
+
 		availabletasks:
 		availabletasks:
 			tasks:
 			tasks:
 				options:
 				options:
-		            filter: 'exclude',
-		            tasks: [
-		            	'coffee'
-		            	'less'
-		            	'clean'
-		            	'mochaTest'
-		            	'availabletasks'
-		            	'wrap_sharejs'
-		            	'requirejs'
-		            	'execute'
-		            	'bunyan'
-		           	]
-		            groups:
-		            	"Compile tasks": [
-		            		"compile:server"
-		            		"compile:client"
-		            		"compile:tests"
-		            		"compile"
-		            		"compile:unit_tests"
-		            		"compile:smoke_tests"
-		            		"compile:css"
-		            		"compile:minify"
-		            		"install"
-		            	]
-		            	"Test tasks": [
-		            		"test:unit"
-		            		"test:acceptance"
-		            	]
-		            	"Run tasks": [
-		            		"run"
-		            		"default"
-		            	]
-		            	"Misc": [
-		            		"help"
-		            	]
+					filter: 'exclude',
+					tasks: [
+						'coffee'
+						'less'
+						'clean'
+						'mochaTest'
+						'availabletasks'
+						'wrap_sharejs'
+						'requirejs'
+						'execute'
+						'bunyan'
+					]
+					groups:
+						"Compile tasks": [
+							"compile:server"
+							"compile:client"
+							"compile:tests"
+							"compile"
+							"compile:unit_tests"
+							"compile:smoke_tests"
+							"compile:css"
+							"compile:minify"
+							"install"
+						]
+						"Test tasks": [
+							"test:unit"
+							"test:acceptance"
+						]
+						"Run tasks": [
+							"run"
+							"default"
+						]
+						"Misc": [
+							"help"
+						]
 
 
 	moduleCompileServerTasks = []
 	moduleCompileServerTasks = []
 	moduleCompileUnitTestTasks = []
 	moduleCompileUnitTestTasks = []
@@ -304,12 +363,14 @@ module.exports = (grunt) ->
 	grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server']
 	grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server']
 	grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes']
 	grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes']
 	grunt.registerTask 'compile:css', 'Compile the less files to css', ['less']
 	grunt.registerTask 'compile:css', 'Compile the less files to css', ['less']
-	grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append"]
+	grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append", "exec:cssmin",]
 	grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests']
 	grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests']
 	grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests']
 	grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests']
 	grunt.registerTask 'compile:smoke_tests', 'Compile the smoke tests', ['coffee:smoke_tests']
 	grunt.registerTask 'compile:smoke_tests', 'Compile the smoke tests', ['coffee:smoke_tests']
 	grunt.registerTask 'compile:tests', 'Compile all the tests', ['compile:smoke_tests', 'compile:unit_tests']
 	grunt.registerTask 'compile:tests', 'Compile all the tests', ['compile:smoke_tests', 'compile:unit_tests']
 	grunt.registerTask 'compile', 'Compiles everything need to run web-sharelatex', ['compile:server', 'compile:client', 'compile:css']
 	grunt.registerTask 'compile', 'Compiles everything need to run web-sharelatex', ['compile:server', 'compile:client', 'compile:css']
+	grunt.registerTask 'quickcompile:coffee', 'Compiles only changed coffee files',['newer:coffee']
+
 
 
 	grunt.registerTask 'install', "Compile everything when installing as an npm module", ['compile']
 	grunt.registerTask 'install', "Compile everything when installing as an npm module", ['compile']
 
 
@@ -319,11 +380,13 @@ module.exports = (grunt) ->
 	
 	
 	grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks)
 	grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks)
 
 
-	grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'bunyan', 'env:run', 'execute']
+	grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'parallel']
+
 	grunt.registerTask 'default', 'run'
 	grunt.registerTask 'default', 'run'
 
 
 	grunt.registerTask 'version', "Write the version number into sentry.jade", ['git-rev-parse', 'sed']
 	grunt.registerTask 'version', "Write the version number into sentry.jade", ['git-rev-parse', 'sed']
 
 
+
 	grunt.registerTask 'create-admin-user', "Create a user with the given email address and make them an admin. Update in place if the user already exists", () ->
 	grunt.registerTask 'create-admin-user', "Create a user with the given email address and make them an admin. Update in place if the user already exists", () ->
 		done = @async()
 		done = @async()
 		email = grunt.option("email")
 		email = grunt.option("email")
@@ -356,4 +419,24 @@ module.exports = (grunt) ->
 							#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}
 							#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}
 						
 						
 					"""
 					"""
-					done()
+					done()
+
+	grunt.registerTask 'delete-user', "deletes a user and all their data", () ->
+		done = @async()
+		email = grunt.option("email")
+		if !email?
+			console.error "Usage: grunt delete-user --email joe@example.com"
+			process.exit(1)
+		settings = require "settings-sharelatex"
+		UserGetter = require "./app/js/Features/User/UserGetter"
+		UserDeleter = require "./app/js/Features/User/UserDeleter"
+		UserGetter.getUser email:email, (error, user) ->
+			if error?
+				throw error
+			if !user?
+				console.log("user #{email} not in database, potentially already deleted")
+				return done()
+			UserDeleter.deleteUser user._id, (err)->
+				if err?
+					throw err
+				done()

+ 16 - 0
services/web/README.md

@@ -6,9 +6,17 @@ web-sharelatex is the front-end web service of the open-source web-based collabo
 It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains 
 It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains 
 a lot of logic around creating and editing projects, and account management.
 a lot of logic around creating and editing projects, and account management.
 
 
+
 The rest of the ShareLaTeX stack, along with information about contributing can be found in the 
 The rest of the ShareLaTeX stack, along with information about contributing can be found in the 
 [sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
 [sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
 
 
+Build process
+----------------
+
+web-sharelatex uses [Grunt](http://gruntjs.com/) to build its front-end related assets.
+
+Image processing tasks are commented out in the gruntfile and the needed packages aren't presently in the project's `package.json`. If the images need to be processed again (minified and sprited), start by fetching the packages (`npm install grunt-contrib-imagemin grunt-sprity`), then *decomment* the tasks in `Gruntfile.coffee`. After this, the tasks can be called (explicitly, via `grunt imagemin` and `grunt sprity`).
+
 Unit test status
 Unit test status
 ----------------
 ----------------
 
 
@@ -42,3 +50,11 @@ in the `public/img/iconshock` directory found via
 [findicons.com](http://findicons.com/icon/498089/height?id=526085#)
 [findicons.com](http://findicons.com/icon/498089/height?id=526085#)
 
 
 
 
+## Acceptance Tests
+
+To run the Acceptance tests:
+
+- set `allowPublicAccess` to true, either in the configuration file,
+  or by setting the environment variable `SHARELATEX_ALLOW_PUBLIC_ACCESS` to `true`
+- start the server (`grunt`)
+- in a separate terminal, run `grunt test:acceptance`

+ 9 - 4
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -9,11 +9,12 @@ Url = require("url")
 Settings = require "settings-sharelatex"
 Settings = require "settings-sharelatex"
 basicAuth = require('basic-auth-connect')
 basicAuth = require('basic-auth-connect')
 UserHandler = require("../User/UserHandler")
 UserHandler = require("../User/UserHandler")
+UserSessionsManager = require("../User/UserSessionsManager")
 
 
 module.exports = AuthenticationController =
 module.exports = AuthenticationController =
 	login: (req, res, next = (error) ->) ->
 	login: (req, res, next = (error) ->) ->
 		AuthenticationController.doLogin req.body, req, res, next
 		AuthenticationController.doLogin req.body, req, res, next
-	
+
 	doLogin: (options, req, res, next) ->
 	doLogin: (options, req, res, next) ->
 		email = options.email?.toLowerCase()
 		email = options.email?.toLowerCase()
 		password = options.password
 		password = options.password
@@ -61,7 +62,7 @@ module.exports = AuthenticationController =
 	requireLogin: () ->
 	requireLogin: () ->
 		doRequest = (req, res, next = (error) ->) ->
 		doRequest = (req, res, next = (error) ->) ->
 			if !req.session.user?
 			if !req.session.user?
-				AuthenticationController._redirectToLoginOrRegisterPage(req, res) 
+				AuthenticationController._redirectToLoginOrRegisterPage(req, res)
 			else
 			else
 				req.user = req.session.user
 				req.user = req.session.user
 				return next()
 				return next()
@@ -92,9 +93,9 @@ module.exports = AuthenticationController =
 
 
 	_redirectToLoginOrRegisterPage: (req, res)->
 	_redirectToLoginOrRegisterPage: (req, res)->
 		if req.query.zipUrl? or req.query.project_name?
 		if req.query.zipUrl? or req.query.project_name?
-			return AuthenticationController._redirectToRegisterPage(req, res) 
+			return AuthenticationController._redirectToRegisterPage(req, res)
 		else
 		else
-			AuthenticationController._redirectToLoginPage(req, res) 
+			AuthenticationController._redirectToLoginPage(req, res)
 
 
 
 
 	_redirectToLoginPage: (req, res) ->
 	_redirectToLoginPage: (req, res) ->
@@ -132,6 +133,8 @@ module.exports = AuthenticationController =
 			isAdmin: user.isAdmin
 			isAdmin: user.isAdmin
 			email: user.email
 			email: user.email
 			referal_id: user.referal_id
 			referal_id: user.referal_id
+			session_created: (new Date()).toISOString()
+			ip_address: req.ip
 		# Regenerate the session to get a new sessionID (cookie value) to
 		# Regenerate the session to get a new sessionID (cookie value) to
 		# protect against session fixation attacks
 		# protect against session fixation attacks
 		oldSession = req.session
 		oldSession = req.session
@@ -141,4 +144,6 @@ module.exports = AuthenticationController =
 			req.session[key] = value
 			req.session[key] = value
 
 
 		req.session.user = lightUser
 		req.session.user = lightUser
+
+		UserSessionsManager.trackSession(user, req.sessionID, () ->)
 		callback()
 		callback()

+ 14 - 2
services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee

@@ -1,8 +1,11 @@
+Settings = require "settings-sharelatex"
 User = require("../../models/User").User
 User = require("../../models/User").User
 {db, ObjectId} = require("../../infrastructure/mongojs")
 {db, ObjectId} = require("../../infrastructure/mongojs")
 crypto = require 'crypto'
 crypto = require 'crypto'
 bcrypt = require 'bcrypt'
 bcrypt = require 'bcrypt'
 
 
+BCRYPT_ROUNDS = Settings?.security?.bcryptRounds or 12
+
 module.exports = AuthenticationManager =
 module.exports = AuthenticationManager =
 	authenticate: (query, password, callback = (error, user) ->) ->
 	authenticate: (query, password, callback = (error, user) ->) ->
 		# Using Mongoose for legacy reasons here. The returned User instance
 		# Using Mongoose for legacy reasons here. The returned User instance
@@ -15,7 +18,9 @@ module.exports = AuthenticationManager =
 					bcrypt.compare password, user.hashedPassword, (error, match) ->
 					bcrypt.compare password, user.hashedPassword, (error, match) ->
 						return callback(error) if error?
 						return callback(error) if error?
 						if match
 						if match
-							callback null, user
+							AuthenticationManager.checkRounds user, user.hashedPassword, password, (err) ->
+								return callback(err) if err?
+								callback null, user
 						else
 						else
 							callback null, null
 							callback null, null
 				else
 				else
@@ -24,7 +29,7 @@ module.exports = AuthenticationManager =
 				callback null, null
 				callback null, null
 
 
 	setUserPassword: (user_id, password, callback = (error) ->) ->
 	setUserPassword: (user_id, password, callback = (error) ->) ->
-		bcrypt.genSalt 7, (error, salt) ->
+		bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			bcrypt.hash password, salt, (error, hash) ->
 			bcrypt.hash password, salt, (error, hash) ->
 				return callback(error) if error?
 				return callback(error) if error?
@@ -35,3 +40,10 @@ module.exports = AuthenticationManager =
 					$unset: password: true
 					$unset: password: true
 				}, callback)
 				}, callback)
 
 
+	checkRounds: (user, hashedPassword, password, callback = (error) ->) ->
+		# check current number of rounds and rehash if necessary
+		currentRounds = bcrypt.getRounds hashedPassword
+		if currentRounds < BCRYPT_ROUNDS
+			AuthenticationManager.setUserPassword user._id, password, callback
+		else
+			callback()

+ 39 - 0
services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee

@@ -0,0 +1,39 @@
+BetaProgramHandler = require './BetaProgramHandler'
+UserLocator = require "../User/UserLocator"
+Settings = require "settings-sharelatex"
+logger = require 'logger-sharelatex'
+
+
+module.exports = BetaProgramController =
+
+	optIn: (req, res, next) ->
+		user_id = req?.session?.user?._id
+		logger.log {user_id}, "user opting in to beta program"
+		if !user_id
+			return next(new Error("no user id in session"))
+		BetaProgramHandler.optIn user_id, (err) ->
+			if err
+				return next(err)
+			return res.redirect "/beta/participate"
+
+	optOut: (req, res, next) ->
+		user_id = req?.session?.user?._id
+		logger.log {user_id}, "user opting out of beta program"
+		if !user_id
+			return next(new Error("no user id in session"))
+		BetaProgramHandler.optOut user_id, (err) ->
+			if err
+				return next(err)
+			return res.redirect "/beta/participate"
+
+	optInPage: (req, res, next)->
+		user_id = req.session?.user?._id
+		logger.log {user_id}, "showing beta participation page for user"
+		UserLocator.findById user_id, (err, user)->
+			if err
+				logger.err {err, user_id}, "error fetching user"
+				return next(err)
+			res.render 'beta_program/opt_in',
+				title:'sharelatex_beta_program'
+				user: user,
+				languages: Settings.languages,

+ 31 - 0
services/web/app/coffee/Features/BetaProgram/BetaProgramHandler.coffee

@@ -0,0 +1,31 @@
+User = require("../../models/User").User
+logger = require 'logger-sharelatex'
+metrics = require("../../infrastructure/Metrics")
+
+module.exports = BetaProgramHandler =
+
+	optIn: (user_id, callback=(err)->) ->
+		User.findById user_id, (err, user) ->
+			if err
+				logger.err {err, user_id}, "problem adding user to beta"
+				return callback(err)
+			metrics.inc "beta-program.opt-in"
+			user.betaProgram = true
+			user.save (err) ->
+				if err
+					logger.err {err, user_id}, "problem adding user to beta"
+					return callback(err)
+				return callback(null)
+
+	optOut: (user_id, callback=(err)->) ->
+		User.findById user_id, (err, user) ->
+			if err
+				logger.err {err, user_id}, "problem removing user from beta"
+				return callback(err)
+			metrics.inc "beta-program.opt-out"
+			user.betaProgram = false
+			user.save (err) ->
+				if err
+					logger.err {err, user_id}, "problem removing user from beta"
+					return callback(err)
+				return callback(null)

+ 3 - 1
services/web/app/coffee/Features/Blog/BlogController.coffee

@@ -20,13 +20,15 @@ module.exports = BlogController =
 
 
 		logger.log url:url, "proxying request to blog api"
 		logger.log url:url, "proxying request to blog api"
 		request.get blogUrl, (err, r, data)->
 		request.get blogUrl, (err, r, data)->
-			if r?.statusCode == 404
+			if r?.statusCode == 404 or r?.statusCode == 403
 				return ErrorController.notFound(req, res, next)
 				return ErrorController.notFound(req, res, next)
 			if err?
 			if err?
 				return res.send 500
 				return res.send 500
 			data = data.trim()
 			data = data.trim()
 			try
 			try
 				data = JSON.parse(data)
 				data = JSON.parse(data)
+				if settings.cdn?.web?.host?
+					data?.content = data?.content?.replace(/src="([^"]+)"/g, "src='#{settings.cdn?.web?.host}$1'");
 			catch err
 			catch err
 				logger.err err:err, data:data, "error parsing data from data"
 				logger.err err:err, data:data, "error parsing data from data"
 			res.render "blog/blog_holder", data
 			res.render "blog/blog_holder", data

+ 19 - 2
services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee

@@ -30,12 +30,17 @@ module.exports = CollaboratorsHandler =
 	getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 	getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
 			return callback(error) if error?
 			return callback(error) if error?
+			result = []
 			async.mapLimit members, 3,
 			async.mapLimit members, 3,
 				(member, cb) ->
 				(member, cb) ->
 					UserGetter.getUser member.id, (error, user) ->
 					UserGetter.getUser member.id, (error, user) ->
 						return cb(error) if error?
 						return cb(error) if error?
-						return cb(null, { user: user, privilegeLevel: member.privilegeLevel })
-				callback
+						if user?
+							result.push { user: user, privilegeLevel: member.privilegeLevel }
+						cb()
+				(error) ->
+					return callback(error) if error?
+					callback null, result
 	
 	
 	getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
 	getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
 		# In future if the schema changes and getting all member ids is more expensive (multiple documents)
 		# In future if the schema changes and getting all member ids is more expensive (multiple documents)
@@ -82,6 +87,18 @@ module.exports = CollaboratorsHandler =
 				logger.error err: err, "problem removing user from project collaberators"
 				logger.error err: err, "problem removing user from project collaberators"
 			callback(err)
 			callback(err)
 	
 	
+	removeUserFromAllProjets: (user_id, callback = (error) ->) ->
+		CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) ->
+			return callback(error) if error?
+			allProjects = readAndWriteProjects.concat(readOnlyProjects)
+			jobs = []
+			for project in allProjects
+				do (project) ->
+					jobs.push (cb) ->
+						return cb() if !project?
+						CollaboratorsHandler.removeUserFromProject project._id, user_id, cb
+			async.series jobs, callback
+	
 	addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
 	addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
 		emails = mimelib.parseAddresses(unparsed_email)
 		emails = mimelib.parseAddresses(unparsed_email)
 		email = emails[0]?.address?.toLowerCase()
 		email = emails[0]?.address?.toLowerCase()

+ 68 - 0
services/web/app/coffee/Features/Compile/ClsiFormatChecker.coffee

@@ -0,0 +1,68 @@
+_ = require("lodash")
+async = require("async")
+
+module.exports = ClsiFormatChecker =
+
+	checkRecoursesForProblems: (resources, callback)->
+		jobs = 
+			conflictedPaths: (cb)->
+				ClsiFormatChecker._checkForConflictingPaths resources, cb
+
+			sizeCheck: (cb)->
+				ClsiFormatChecker._checkDocsAreUnderSizeLimit resources, cb
+
+		async.series jobs, (err, problems)->
+			if err? 
+				return callback(err)
+			
+			problems = _.omitBy(problems, _.isEmpty)
+
+			if _.isEmpty(problems)
+				return callback()
+			else
+				callback(null, problems)
+
+
+	_checkForConflictingPaths: (resources, callback)->
+		paths = _.map(resources, 'path')
+
+		conflicts = _.filter paths, (path)->
+			matchingPaths = _.filter paths, (checkPath)->
+				return checkPath.indexOf(path+"/") != -1
+
+			return matchingPaths.length > 0
+
+		conflictObjects = _.map conflicts, (conflict)->
+			path:conflict
+
+		callback null, conflictObjects
+
+	_checkDocsAreUnderSizeLimit: (resources, callback)->
+		
+		FIVEMB = 1000 * 1000 * 5
+
+		totalSize = 0
+
+		sizedResources = _.map resources, (resource)->
+			result = {path:resource.path}
+			if resource.content?
+				result.size = resource.content.replace(/\n/g).length
+				result.kbSize = Math.ceil(result.size / 1000)
+			else
+				result.size = 0
+			totalSize += result.size
+			return result
+
+		tooLarge = totalSize > FIVEMB
+		if !tooLarge
+			return callback()
+		else
+			sizedResources = _.sortBy(sizedResources, "size").reverse().slice(0, 10)
+			return callback(null, {resources:sizedResources, totalSize:totalSize})
+
+
+
+
+
+
+

+ 39 - 22
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -7,15 +7,24 @@ ProjectEntityHandler = require("../Project/ProjectEntityHandler")
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 Url = require("url")
 Url = require("url")
 ClsiCookieManager = require("./ClsiCookieManager")
 ClsiCookieManager = require("./ClsiCookieManager")
-
+_ = require("underscore")
+async = require("async")
+ClsiFormatChecker = require("./ClsiFormatChecker")
 
 
 module.exports = ClsiManager =
 module.exports = ClsiManager =
 
 
-	sendRequest: (project_id, options = {}, callback = (error, success) ->) ->
+	sendRequest: (project_id, user_id, options = {}, callback = (error, status, outputFiles, clsiServerId, validationProblems) ->) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			logger.log project_id: project_id, "sending compile to CLSI"
 			logger.log project_id: project_id, "sending compile to CLSI"
-			ClsiManager._postToClsi project_id, req, options.compileGroup, (error, response) ->
+			ClsiFormatChecker.checkRecoursesForProblems req.compile?.resources, (err, validationProblems)->
+				if err?
+					logger.err err, project_id, "could not check resources for potential problems before sending to clsi"
+					return callback(err)
+				if validationProblems?
+					logger.log project_id:project_id, validationProblems:validationProblems, "problems with users latex before compile was attempted"
+					return callback(null, "validation-problems", null, null, validationProblems)
+			ClsiManager._postToClsi project_id, user_id, req, options.compileGroup, (error, response) ->
 				if error?
 				if error?
 					logger.err err:error, project_id:project_id, "error sending request to clsi"
 					logger.err err:error, project_id:project_id, "error sending request to clsi"
 					return callback(error)
 					return callback(error)
@@ -27,13 +36,19 @@ module.exports = ClsiManager =
 					outputFiles = ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles)
 					outputFiles = ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles)
 					callback(null, response?.compile?.status, outputFiles, clsiServerId)
 					callback(null, response?.compile?.status, outputFiles, clsiServerId)
 
 
-	deleteAuxFiles: (project_id, options, callback = (error) ->) ->
-		compilerUrl = @_getCompilerUrl(options?.compileGroup)
+	stopCompile: (project_id, user_id, options, callback = (error) ->) ->
+		compilerUrl = @_getCompilerUrl(options?.compileGroup, project_id, user_id, "compile/stop")
 		opts =
 		opts =
-			url:"#{compilerUrl}/project/#{project_id}"
-			method:"DELETE"
+			url:compilerUrl
+			method:"POST"
 		ClsiManager._makeRequest project_id, opts, callback
 		ClsiManager._makeRequest project_id, opts, callback
 
 
+	deleteAuxFiles: (project_id, user_id, options, callback = (error) ->) ->
+		compilerUrl = @_getCompilerUrl(options?.compileGroup, project_id, user_id)
+		opts =
+			url:compilerUrl
+			method:"DELETE"
+		ClsiManager._makeRequest project_id, opts, callback
 
 
 	_makeRequest: (project_id, opts, callback)->
 	_makeRequest: (project_id, opts, callback)->
 		ClsiCookieManager.getCookieJar project_id, (err, jar)->
 		ClsiCookieManager.getCookieJar project_id, (err, jar)->
@@ -51,14 +66,17 @@ module.exports = ClsiManager =
 						
 						
 					return callback err, response, body
 					return callback err, response, body
 
 
+	_getCompilerUrl: (compileGroup, project_id, user_id, action) ->
+		host = Settings.apis.clsi.url
+		path = "/project/#{project_id}"
+		path += "/user/#{user_id}" if user_id?
+		path += "/#{action}" if action?
+		return "#{host}#{path}"
 
 
-	_getCompilerUrl: (compileGroup) ->
-		return Settings.apis.clsi.url
-
-	_postToClsi: (project_id, req, compileGroup, callback = (error, response) ->) ->
-		compilerUrl = Settings.apis.clsi.url
-		opts = 
-			url:  "#{compilerUrl}/project/#{project_id}/compile"
+	_postToClsi: (project_id, user_id, req, compileGroup, callback = (error, response) ->) ->
+		compileUrl = @_getCompilerUrl(compileGroup, project_id, user_id, "compile")
+		opts =
+			url:  compileUrl
 			json: req
 			json: req
 			method: "POST"
 			method: "POST"
 		ClsiManager._makeRequest project_id, opts, (error, response, body) ->
 		ClsiManager._makeRequest project_id, opts, (error, response, body) ->
@@ -75,10 +93,9 @@ module.exports = ClsiManager =
 	_parseOutputFiles: (project_id, rawOutputFiles = []) ->
 	_parseOutputFiles: (project_id, rawOutputFiles = []) ->
 		outputFiles = []
 		outputFiles = []
 		for file in rawOutputFiles
 		for file in rawOutputFiles
-			path = Url.parse(file.url).path
-			path = path.replace("/project/#{project_id}/output/", "")
 			outputFiles.push
 			outputFiles.push
-				path: path
+				path: file.path # the clsi is now sending this to web
+				url: Url.parse(file.url).path # the location of the file on the clsi, excluding the host part
 				type: file.type
 				type: file.type
 				build: file.build
 				build: file.build
 		return outputFiles
 		return outputFiles
@@ -134,15 +151,15 @@ module.exports = ClsiManager =
 							resources: resources
 							resources: resources
 					}
 					}
 
 
-	wordCount: (project_id, file, options, callback = (error, response) ->) ->
+	wordCount: (project_id, user_id, file, options, callback = (error, response) ->) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
-			compilerUrl = ClsiManager._getCompilerUrl(options?.compileGroup)
 			filename = file || req?.compile?.rootResourcePath
 			filename = file || req?.compile?.rootResourcePath
-			wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}"
-			if req.compile.options.imageName?
-				wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}"
+			wordcount_url = ClsiManager._getCompilerUrl(options?.compileGroup, project_id, user_id, "wordcount")
 			opts =
 			opts =
 				url: wordcount_url
 				url: wordcount_url
+				qs:
+					file: filename
+					image: req.compile.options.imageName
 				method: "GET"
 				method: "GET"
 			ClsiManager._makeRequest project_id, opts, (error, response, body) ->
 			ClsiManager._makeRequest project_id, opts, (error, response, body) ->
 				return callback(error) if error?
 				return callback(error) if error?

+ 82 - 22
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -29,8 +29,8 @@ module.exports = CompileController =
 				options.compiler = req.body.compiler
 				options.compiler = req.body.compiler
 			if req.body?.draft
 			if req.body?.draft
 				options.draft = req.body.draft
 				options.draft = req.body.draft
-			logger.log {options, project_id}, "got compile request"
-			CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits) ->
+			logger.log {options:options, project_id:project_id, user_id:user_id}, "got compile request"
+			CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits, validationProblems) ->
 				return next(error) if error?
 				return next(error) if error?
 				res.contentType("application/json")
 				res.contentType("application/json")
 				res.status(200).send JSON.stringify {
 				res.status(200).send JSON.stringify {
@@ -38,8 +38,32 @@ module.exports = CompileController =
 					outputFiles: outputFiles
 					outputFiles: outputFiles
 					compileGroup: limits?.compileGroup
 					compileGroup: limits?.compileGroup
 					clsiServerId:clsiServerId
 					clsiServerId:clsiServerId
+					validationProblems:validationProblems
 				}
 				}
 
 
+	stopCompile: (req, res, next = (error) ->) ->
+		project_id = req.params.Project_id
+		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+			return next(error) if error?
+			logger.log {project_id:project_id, user_id:user_id}, "stop compile request"
+			CompileManager.stopCompile project_id, user_id, (error) ->
+				return next(error) if error?
+				res.status(200).send()
+
+	_compileAsUser: (req, callback) ->
+		# callback with user_id if per-user, undefined otherwise
+		if not Settings.disablePerUserCompiles
+			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+		else
+			callback() # do a per-project compile, not per-user
+
+	_downloadAsUser: (req, callback) ->
+		# callback with user_id if per-user, undefined otherwise
+		if not Settings.disablePerUserCompiles
+			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+		else
+			callback() # do a per-project compile, not per-user
+
 	downloadPdf: (req, res, next = (error) ->)->
 	downloadPdf: (req, res, next = (error) ->)->
 		Metrics.inc "pdf-downloads"
 		Metrics.inc "pdf-downloads"
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
@@ -72,16 +96,22 @@ module.exports = CompileController =
 					logger.log project_id:project_id, ip:req.ip, "rate limit hit downloading pdf"
 					logger.log project_id:project_id, ip:req.ip, "rate limit hit downloading pdf"
 					res.send 500
 					res.send 500
 				else
 				else
-					CompileController.proxyToClsi(project_id, "/project/#{project_id}/output/output.pdf", req, res, next)
+					CompileController._downloadAsUser req, (error, user_id) ->
+						url = CompileController._getFileUrl project_id, user_id, req.params.build_id, "output.pdf"
+						CompileController.proxyToClsi(project_id, url, req, res, next)
 
 
 	deleteAuxFiles: (req, res, next) ->
 	deleteAuxFiles: (req, res, next) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
-		CompileManager.deleteAuxFiles project_id, (error) ->
+		CompileController._compileAsUser req, (error, user_id) ->
 			return next(error) if error?
 			return next(error) if error?
-			res.sendStatus(200)
+			CompileManager.deleteAuxFiles project_id, user_id, (error) ->
+				return next(error) if error?
+				res.sendStatus(200)
 
 
+	# this is only used by templates, so is not called with a user_id
 	compileAndDownloadPdf: (req, res, next)->
 	compileAndDownloadPdf: (req, res, next)->
 		project_id = req.params.project_id
 		project_id = req.params.project_id
+		# pass user_id as null, since templates are an "anonymous" compile
 		CompileManager.compile project_id, null, {}, (err)->
 		CompileManager.compile project_id, null, {}, (err)->
 			if err?
 			if err?
 				logger.err err:err, project_id:project_id, "something went wrong compile and downloading pdf"
 				logger.err err:err, project_id:project_id, "something went wrong compile and downloading pdf"
@@ -91,12 +121,28 @@ module.exports = CompileController =
 
 
 	getFileFromClsi: (req, res, next = (error) ->) ->
 	getFileFromClsi: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
-		build = req.params.build
-		if build?
-			url = "/project/#{project_id}/build/#{build}/output/#{req.params.file}"
+		CompileController._downloadAsUser req, (error, user_id) ->
+			return next(error) if error?
+			url = CompileController._getFileUrl project_id, user_id, req.params.build_id, req.params.file
+			CompileController.proxyToClsi(project_id, url, req, res, next)
+
+	# compute a GET file url for a given project, user (optional), build (optional) and file
+	_getFileUrl: (project_id, user_id, build_id, file) ->
+		if user_id? and build_id?
+			url = "/project/#{project_id}/user/#{user_id}/build/#{build_id}/output/#{file}"
+		else if user_id?
+			url = "/project/#{project_id}/user/#{user_id}/output/#{file}"
+		else if build_id?
+			url = "/project/#{project_id}/build/#{build_id}/output/#{file}"
 		else
 		else
-			url = "/project/#{project_id}/output/#{req.params.file}"
-		CompileController.proxyToClsi(project_id, url, req, res, next)
+			url = "/project/#{project_id}/output/#{file}"
+		return url
+
+	# compute a POST url for a project, user (optional) and action
+	_getUrl: (project_id, user_id, action) ->
+		path = "/project/#{project_id}"
+		path += "/user/#{user_id}" if user_id?
+		return "#{path}/#{action}"
 
 
 	proxySyncPdf: (req, res, next = (error) ->) ->
 	proxySyncPdf: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
@@ -107,20 +153,35 @@ module.exports = CompileController =
 			return next(new Error("invalid h parameter"))
 			return next(new Error("invalid h parameter"))
 		if not v?.match(/^\d+\.\d+$/)
 		if not v?.match(/^\d+\.\d+$/)
 			return next(new Error("invalid v parameter"))
 			return next(new Error("invalid v parameter"))
-		destination = {url: "/project/#{project_id}/sync/pdf", qs: {page, h, v}}
-		CompileController.proxyToClsi(project_id, destination, req, res, next)
+		# whether this request is going to a per-user container
+		CompileController._compileAsUser req, (error, user_id) ->
+			return next(error) if error?
+			url = CompileController._getUrl(project_id, user_id, "sync/pdf")
+			destination = {url: url, qs: {page, h, v}}
+			CompileController.proxyToClsi(project_id, destination, req, res, next)
 
 
 	proxySyncCode: (req, res, next = (error) ->) ->
 	proxySyncCode: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		{file, line, column} = req.query
 		{file, line, column} = req.query
-		if not file? or Path.resolve("/", file) isnt "/#{file}"
+		if not file?
+			return next(new Error("missing file parameter"))
+		# Check that we are dealing with a simple file path (this is not
+		# strictly needed because synctex uses this parameter as a label
+		# to look up in the synctex output, and does not open the file
+		# itself).  Since we have valid synctex paths like foo/./bar we
+		# allow those by replacing /./ with /
+		testPath = file.replace '/./', '/'
+		if Path.resolve("/", testPath) isnt "/#{testPath}"
 			return next(new Error("invalid file parameter"))
 			return next(new Error("invalid file parameter"))
 		if not line?.match(/^\d+$/)
 		if not line?.match(/^\d+$/)
 			return next(new Error("invalid line parameter"))
 			return next(new Error("invalid line parameter"))
 		if not column?.match(/^\d+$/)
 		if not column?.match(/^\d+$/)
 			return next(new Error("invalid column parameter"))
 			return next(new Error("invalid column parameter"))
-		destination = {url:"/project/#{project_id}/sync/code", qs: {file, line, column}}
-		CompileController.proxyToClsi(project_id, destination, req, res, next)
+		CompileController._compileAsUser req, (error, user_id) ->
+			return next(error) if error?
+			url = CompileController._getUrl(project_id, user_id, "sync/code")
+			destination = {url:url, qs: {file, line, column}}
+			CompileController.proxyToClsi(project_id, destination, req, res, next)
 
 
 	proxyToClsi: (project_id, url, req, res, next = (error) ->) ->
 	proxyToClsi: (project_id, url, req, res, next = (error) ->) ->
 		if req.query?.compileGroup
 		if req.query?.compileGroup
@@ -138,10 +199,7 @@ module.exports = CompileController =
 			# expand any url parameter passed in as {url:..., qs:...}
 			# expand any url parameter passed in as {url:..., qs:...}
 			if typeof url is "object"
 			if typeof url is "object"
 				{url, qs} = url
 				{url, qs} = url
-			if limits.compileGroup == "priority"
-				compilerUrl = Settings.apis.clsi_priority.url
-			else
-				compilerUrl = Settings.apis.clsi.url
+			compilerUrl = Settings.apis.clsi.url
 			url = "#{compilerUrl}#{url}"
 			url = "#{compilerUrl}#{url}"
 			logger.log url: url, "proxying to CLSI"
 			logger.log url: url, "proxying to CLSI"
 			oneMinute = 60 * 1000
 			oneMinute = 60 * 1000
@@ -168,7 +226,9 @@ module.exports = CompileController =
 	wordCount: (req, res, next) ->
 	wordCount: (req, res, next) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		file   = req.query.file || false
 		file   = req.query.file || false
-		CompileManager.wordCount project_id, file, (error, body) ->
+		CompileController._compileAsUser req, (error, user_id) ->
 			return next(error) if error?
 			return next(error) if error?
-			res.contentType("application/json")
-			res.send body
+			CompileManager.wordCount project_id, user_id, file, (error, body) ->
+				return next(error) if error?
+				res.contentType("application/json")
+				res.send body

+ 14 - 6
services/web/app/coffee/Features/Compile/CompileManager.coffee

@@ -37,15 +37,23 @@ module.exports = CompileManager =
 							return callback(error) if error?
 							return callback(error) if error?
 							for key, value of limits
 							for key, value of limits
 								options[key] = value
 								options[key] = value
-							ClsiManager.sendRequest project_id, options, (error, status, outputFiles, clsiServerId) ->
+							# only pass user_id down to clsi if this is a per-user compile
+							compileAsUser = if Settings.disablePerUserCompiles then undefined else user_id
+							ClsiManager.sendRequest project_id, compileAsUser, options, (error, status, outputFiles, clsiServerId, validationProblems) ->
 								return callback(error) if error?
 								return callback(error) if error?
 								logger.log files: outputFiles, "output files"
 								logger.log files: outputFiles, "output files"
-								callback(null, status, outputFiles, clsiServerId, limits)
+								callback(null, status, outputFiles, clsiServerId, limits, validationProblems)
 								
 								
-	deleteAuxFiles: (project_id, callback = (error) ->) ->
+
+	stopCompile: (project_id, user_id, callback = (error) ->) ->
+		CompileManager.getProjectCompileLimits project_id, (error, limits) ->
+			return callback(error) if error?
+			ClsiManager.stopCompile project_id, user_id, limits, callback
+
+	deleteAuxFiles: (project_id, user_id, callback = (error) ->) ->
 		CompileManager.getProjectCompileLimits project_id, (error, limits) ->
 		CompileManager.getProjectCompileLimits project_id, (error, limits) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			ClsiManager.deleteAuxFiles project_id, limits, callback
+			ClsiManager.deleteAuxFiles project_id, user_id, limits, callback
 
 
 	getProjectCompileLimits: (project_id, callback = (error, limits) ->) ->
 	getProjectCompileLimits: (project_id, callback = (error, limits) ->) ->
 		Project.findById project_id, {owner_ref: 1}, (error, project) ->
 		Project.findById project_id, {owner_ref: 1}, (error, project) ->
@@ -92,7 +100,7 @@ module.exports = CompileManager =
 			else
 			else
 				ProjectRootDocManager.setRootDocAutomatically project_id, callback
 				ProjectRootDocManager.setRootDocAutomatically project_id, callback
 		
 		
-	wordCount: (project_id, file, callback = (error) ->) ->
+	wordCount: (project_id, user_id, file, callback = (error) ->) ->
 		CompileManager.getProjectCompileLimits project_id, (error, limits) ->
 		CompileManager.getProjectCompileLimits project_id, (error, limits) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			ClsiManager.wordCount project_id, file, limits, callback
+			ClsiManager.wordCount project_id, user_id, file, limits, callback

+ 4 - 0
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -3,6 +3,8 @@ PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
 
 
+
+
 templates = {}
 templates = {}
 
 
 templates.registered =	
 templates.registered =	
@@ -114,6 +116,8 @@ module.exports =
 		template = templates[templateName]
 		template = templates[templateName]
 		opts.siteUrl = settings.siteUrl
 		opts.siteUrl = settings.siteUrl
 		opts.body = template.compiledTemplate(opts)
 		opts.body = template.compiledTemplate(opts)
+		if settings.email?.templates?.customFooter?
+			opts.body += settings.email?.templates?.customFooter
 		return {
 		return {
 			subject : template.subject(opts)
 			subject : template.subject(opts)
 			html: template.layout(opts)
 			html: template.layout(opts)

+ 2 - 2
services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee

@@ -1,15 +1,15 @@
-
+logger = require("logger-sharelatex")
 NotificationsHandler = require("./NotificationsHandler")
 NotificationsHandler = require("./NotificationsHandler")
 
 
 module.exports = 
 module.exports = 
 
 
 	groupPlan: (user, licence)->
 	groupPlan: (user, licence)->
 		key : "join-sub-#{licence.subscription_id}"
 		key : "join-sub-#{licence.subscription_id}"
-
 		create: (callback = ->)->
 		create: (callback = ->)->
 			messageOpts = 
 			messageOpts = 
 				groupName: licence.name
 				groupName: licence.name
 				subscription_id: licence.subscription_id
 				subscription_id: licence.subscription_id
+			logger.log user_id:user._id, key:key, "creating notification key for user"
 			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback
 			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback
 
 
 		read: (callback = ->)->
 		read: (callback = ->)->

+ 19 - 8
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -3,14 +3,22 @@ request = require("request")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 
 
 oneSecond = 1000
 oneSecond = 1000
+
+makeRequest = (opts, callback)->
+	if !settings.apis.notifications?.url?
+		return callback(null, statusCode:200)
+	else
+		request(opts, callback)
+
 module.exports = 
 module.exports = 
 
 
 	getUserNotifications: (user_id, callback)->
 	getUserNotifications: (user_id, callback)->
 		opts = 
 		opts = 
-			uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			json: true
 			json: true
 			timeout: oneSecond
 			timeout: oneSecond
-		request.get opts, (err, res, unreadNotifications)->
+			method: "GET"
+		makeRequest opts, (err, res, unreadNotifications)->
 			statusCode =  if res? then res.statusCode else 500
 			statusCode =  if res? then res.statusCode else 500
 			if err? or statusCode != 200
 			if err? or statusCode != 200
 				e = new Error("something went wrong getting notifications, #{err}, #{statusCode}")
 				e = new Error("something went wrong getting notifications, #{err}, #{statusCode}")
@@ -23,30 +31,33 @@ module.exports =
 
 
 	createNotification: (user_id, key, templateKey, messageOpts, callback)->
 	createNotification: (user_id, key, templateKey, messageOpts, callback)->
 		opts = 
 		opts = 
-			uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			timeout: oneSecond
 			timeout: oneSecond
+			method:"POST"
 			json: {
 			json: {
 				key:key
 				key:key
 				messageOpts:messageOpts
 				messageOpts:messageOpts
 				templateKey:templateKey
 				templateKey:templateKey
 			}
 			}
 		logger.log opts:opts, "creating notification for user"
 		logger.log opts:opts, "creating notification for user"
-		request.post opts, callback
+		makeRequest opts, callback
 
 
 	markAsReadWithKey: (user_id, key, callback)->
 	markAsReadWithKey: (user_id, key, callback)->
 		opts = 
 		opts = 
-			uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
+			method: "DELETE"
 			timeout: oneSecond
 			timeout: oneSecond
 			json: {
 			json: {
 				key:key
 				key:key
 			}
 			}
 		logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
 		logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
-		request.del opts, callback
+		makeRequest opts, callback
 	
 	
 
 
 	markAsRead: (user_id, notification_id, callback)->
 	markAsRead: (user_id, notification_id, callback)->
 		opts =
 		opts =
-			uri: "#{settings.apis.notifications.url}/user/#{user_id}/notification/#{notification_id}"
+			method: "DELETE"
+			uri: "#{settings.apis.notifications?.url}/user/#{user_id}/notification/#{notification_id}"
 			timeout:oneSecond
 			timeout:oneSecond
 		logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
 		logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
-		request.del opts, callback
+		makeRequest opts, callback

+ 9 - 6
services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee

@@ -2,6 +2,7 @@ PasswordResetHandler = require("./PasswordResetHandler")
 RateLimiter = require("../../infrastructure/RateLimiter")
 RateLimiter = require("../../infrastructure/RateLimiter")
 AuthenticationController = require("../Authentication/AuthenticationController")
 AuthenticationController = require("../Authentication/AuthenticationController")
 UserGetter = require("../User/UserGetter")
 UserGetter = require("../User/UserGetter")
+UserSessionsManager = require("../User/UserSessionsManager")
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 
 
 module.exports =
 module.exports =
@@ -47,11 +48,13 @@ module.exports =
 		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
 		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
 			return next(err) if err?
 			return next(err) if err?
 			if found
 			if found
-				if req.body.login_after
-					UserGetter.getUser user_id, {email: 1}, (err, user) ->
-						return next(err) if err?
-						AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
-				else
-					res.sendStatus 200
+				UserSessionsManager.revokeAllUserSessions {_id: user_id}, [], (err) ->
+					return next(err) if err?
+					if req.body.login_after
+						UserGetter.getUser user_id, {email: 1}, (err, user) ->
+							return next(err) if err?
+							AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+					else
+						res.sendStatus 200
 			else
 			else
 				res.sendStatus 404
 				res.sendStatus 404

+ 2 - 0
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -245,11 +245,13 @@ module.exports = ProjectController =
 						first_name : user.first_name
 						first_name : user.first_name
 						last_name  : user.last_name
 						last_name  : user.last_name
 						referal_id : user.referal_id
 						referal_id : user.referal_id
+						signUpDate : user.signUpDate
 						subscription :
 						subscription :
 							freeTrial: {allowed: allowedFreeTrial}
 							freeTrial: {allowed: allowedFreeTrial}
 						featureSwitches: user.featureSwitches
 						featureSwitches: user.featureSwitches
 						features: user.features
 						features: user.features
 						refProviders: user.refProviders
 						refProviders: user.refProviders
+						betaProgram: user.betaProgram
 					}
 					}
 					userSettings: {
 					userSettings: {
 						mode  : user.ace.mode
 						mode  : user.ace.mode

+ 5 - 3
services/web/app/coffee/Features/Project/ProjectDeleter.coffee

@@ -24,9 +24,11 @@ module.exports = ProjectDeleter =
 		update = {deletedByExternalDataSource: false}
 		update = {deletedByExternalDataSource: false}
 		Project.update conditions, update, {}, callback
 		Project.update conditions, update, {}, callback
 
 
-	deleteUsersProjects: (owner_id, callback)->
-		logger.log owner_id:owner_id, "deleting users projects"
-		Project.remove owner_ref:owner_id, callback
+	deleteUsersProjects: (user_id, callback)->
+		logger.log {user_id}, "deleting users projects"
+		Project.remove owner_ref:user_id, (error) ->
+			return callback(error) if error?
+			CollaboratorsHandler.removeUserFromAllProjets user_id, callback
 
 
 	deleteProject: (project_id, callback = (error) ->) ->
 	deleteProject: (project_id, callback = (error) ->) ->
 		# archiveProject takes care of the clean-up
 		# archiveProject takes care of the clean-up

+ 5 - 0
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -512,6 +512,11 @@ module.exports = ProjectEntityHandler =
 			return callback(e)
 			return callback(e)
 		type = sanitizeTypeOfElement type
 		type = sanitizeTypeOfElement type
 
 
+		if path.resolve("/", element.name) isnt "/#{element.name}" or element.name.match("/")
+			e = new Error("invalid element name")
+			logger.err project_id:project._id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as name was invalid"
+			return callback(e)
+
 		if !folder_id?
 		if !folder_id?
 			folder_id = project.rootFolder[0]._id
 			folder_id = project.rootFolder[0]._id
 		ProjectEntityHandler._countElements project, (err, count)->
 		ProjectEntityHandler._countElements project, (err, count)->

+ 4 - 2
services/web/app/coffee/Features/References/ReferencesController.coffee

@@ -15,7 +15,7 @@ module.exports = ReferencesController =
 			return res.sendStatus 400
 			return res.sendStatus 400
 		logger.log {projectId, docIds: docIds}, "index references for project"
 		logger.log {projectId, docIds: docIds}, "index references for project"
 		ReferencesHandler.index projectId, docIds, (err, data) ->
 		ReferencesHandler.index projectId, docIds, (err, data) ->
-			if err
+			if err?
 				logger.err {err, projectId}, "error indexing all references"
 				logger.err {err, projectId}, "error indexing all references"
 				return res.sendStatus 500
 				return res.sendStatus 500
 			ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
 			ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
@@ -25,12 +25,14 @@ module.exports = ReferencesController =
 		shouldBroadcast = req.body.shouldBroadcast
 		shouldBroadcast = req.body.shouldBroadcast
 		logger.log {projectId}, "index all references for project"
 		logger.log {projectId}, "index all references for project"
 		ReferencesHandler.indexAll projectId, (err, data) ->
 		ReferencesHandler.indexAll projectId, (err, data) ->
-			if err
+			if err?
 				logger.err {err, projectId}, "error indexing all references"
 				logger.err {err, projectId}, "error indexing all references"
 				return res.sendStatus 500
 				return res.sendStatus 500
 			ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
 			ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
 
 
 	_handleIndexResponse: (req, res, projectId, shouldBroadcast, data) ->
 	_handleIndexResponse: (req, res, projectId, shouldBroadcast, data) ->
+		if !data? or !data.keys?
+			return res.json({projectId, keys: []})
 		if shouldBroadcast
 		if shouldBroadcast
 			logger.log {projectId}, "emitting new references keys to connected clients"
 			logger.log {projectId}, "emitting new references keys to connected clients"
 			EditorRealTimeController.emitToRoom projectId, 'references:keys:updated', data.keys
 			EditorRealTimeController.emitToRoom projectId, 'references:keys:updated', data.keys

+ 2 - 0
services/web/app/coffee/Features/References/ReferencesHandler.coffee

@@ -66,6 +66,8 @@ module.exports = ReferencesHandler =
 			ReferencesHandler._doIndexOperation(projectId, project, docIds, [], callback)
 			ReferencesHandler._doIndexOperation(projectId, project, docIds, [], callback)
 
 
 	_doIndexOperation: (projectId, project, docIds, fileIds, callback) ->
 	_doIndexOperation: (projectId, project, docIds, fileIds, callback) ->
+		if !settings.apis?.references?.url?
+			return callback()
 		ReferencesHandler._isFullIndex project, (err, isFullIndex) ->
 		ReferencesHandler._isFullIndex project, (err, isFullIndex) ->
 			if err
 			if err
 				logger.err {err, projectId}, "error checking whether to do full index"
 				logger.err {err, projectId}, "error checking whether to do full index"

+ 32 - 2
services/web/app/coffee/Features/StaticPages/UniversityController.coffee

@@ -5,6 +5,12 @@ _ = require("underscore")
 ErrorController = require "../Errors/ErrorController"
 ErrorController = require "../Errors/ErrorController"
 StaticPageHelpers = require("./StaticPageHelpers")
 StaticPageHelpers = require("./StaticPageHelpers")
 sanitize = require('sanitizer')
 sanitize = require('sanitizer')
+Settings = require("settings-sharelatex")
+contentful = require('contentful')
+marked = require("marked")
+
+
+
 
 
 module.exports = UniversityController = 
 module.exports = UniversityController = 
 
 
@@ -17,7 +23,7 @@ module.exports = UniversityController =
 		logger.log url:url, "proxying request to university api"
 		logger.log url:url, "proxying request to university api"
 		request.get universityUrl, (err, r, data)->
 		request.get universityUrl, (err, r, data)->
 			if r?.statusCode == 404
 			if r?.statusCode == 404
-				return ErrorController.notFound(req, res, next)
+				return UniversityController.getContentfulPage(req, res, next)
 			if err?
 			if err?
 				return res.send 500
 				return res.send 500
 			data = data.trim()
 			data = data.trim()
@@ -37,4 +43,28 @@ module.exports = UniversityController =
 		upstream = request.get(originUrl)
 		upstream = request.get(originUrl)
 		upstream.on "error", (error) ->
 		upstream.on "error", (error) ->
 			logger.error err: error, "university proxy error"
 			logger.error err: error, "university proxy error"
-		upstream.pipe res
+		upstream.pipe res
+
+	getContentfulPage: (req, res, next)->
+		console.log Settings.contentful
+		if !Settings.contentful?.uni?.space? and !Settings.contentful?.uni?.accessToken?
+			return ErrorController.notFound(req, res, next)
+
+		client = contentful.createClient({
+			space: Settings.contentful?.uni?.space
+			accessToken: Settings.contentful?.uni?.accessToken
+		})
+
+		url = req.url?.toLowerCase().replace("/university/","")
+		client.getEntries({content_type: 'caseStudy', 'fields.slug':url})
+			.catch (e)->
+				return res.send 500
+			.then (entry)->
+				if !entry? or !entry.items? or entry.items.length == 0
+					return ErrorController.notFound(req, res, next)
+				viewData = entry.items[0].fields
+				viewData.html = marked(viewData.content)
+				res.render "university/case_study", viewData:viewData
+
+
+

+ 2 - 2
services/web/app/coffee/Features/Subscription/LimitationsManager.coffee

@@ -38,12 +38,12 @@ module.exports =
 		SubscriptionLocator.getUsersSubscription user._id, (err, subscription)->
 		SubscriptionLocator.getUsersSubscription user._id, (err, subscription)->
 			if err?
 			if err?
 				return callback(err)
 				return callback(err)
-			hasValidSubscription = subscription? and subscription.recurlySubscription_id?
+			hasValidSubscription = subscription? and (subscription.recurlySubscription_id? or subscription?.customAccount == true)
 			logger.log user:user, hasValidSubscription:hasValidSubscription, subscription:subscription, "checking if user has subscription"
 			logger.log user:user, hasValidSubscription:hasValidSubscription, subscription:subscription, "checking if user has subscription"
 			callback err, hasValidSubscription, subscription
 			callback err, hasValidSubscription, subscription
 			
 			
 	userIsMemberOfGroupSubscription: (user, callback = (error, isMember, subscriptions) ->) ->
 	userIsMemberOfGroupSubscription: (user, callback = (error, isMember, subscriptions) ->) ->
-		logger.log user_id: user._ud, "checking is user is member of subscription groups"
+		logger.log user_id: user._id, "checking is user is member of subscription groups"
 		SubscriptionLocator.getMemberSubscriptions user._id, (err, subscriptions = []) ->
 		SubscriptionLocator.getMemberSubscriptions user._id, (err, subscriptions = []) ->
 			return callback(err) if err?
 			return callback(err) if err?
 			callback err, subscriptions.length > 0, subscriptions
 			callback err, subscriptions.length > 0, subscriptions

+ 236 - 34
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -4,11 +4,201 @@ request = require 'request'
 Settings = require "settings-sharelatex"
 Settings = require "settings-sharelatex"
 xml2js = require "xml2js"
 xml2js = require "xml2js"
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
+Async = require('async')
 
 
 module.exports = RecurlyWrapper =
 module.exports = RecurlyWrapper =
 	apiUrl : "https://api.recurly.com/v2"
 	apiUrl : "https://api.recurly.com/v2"
 
 
-	createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
+	_addressToXml: (address) ->
+		allowedKeys = ['address1', 'address2', 'city', 'country', 'state', 'zip', 'postal_code']
+		resultString = "<billing_info>\n"
+		for k, v of address
+			if k == 'postal_code'
+				k = 'zip'
+			if v and (k in allowedKeys)
+				resultString += "<#{k}#{if k == 'address2' then ' nil="nil"' else ''}>#{v || ''}</#{k}>\n"
+		resultString += "</billing_info>\n"
+		return resultString
+
+	_paypal:
+		checkAccountExists: (cache, next) ->
+			user = cache.user
+			recurly_token_id = cache.recurly_token_id
+			subscriptionDetails = cache.subscriptionDetails
+			logger.log {user_id: user._id, recurly_token_id}, "checking if recurly account exists for user"
+			RecurlyWrapper.apiRequest({
+				url:    "accounts/#{user._id}"
+				method: "GET"
+			}, (error, response, responseBody) ->
+				if error
+					if response.statusCode == 404  # actually not an error in this case, just no existing account
+						cache.userExists = false
+						return next(null, cache)
+					logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while checking account"
+					return next(error)
+				logger.log {user_id: user._id, recurly_token_id}, "user appears to exist in recurly"
+				RecurlyWrapper._parseAccountXml responseBody, (err, account) ->
+					if err
+						logger.error {err, user_id: user._id, recurly_token_id}, "error parsing account"
+						return next(err)
+					cache.userExists = true
+					cache.account = account
+					return next(null, cache)
+			)
+		createAccount: (cache, next) ->
+			user = cache.user
+			recurly_token_id = cache.recurly_token_id
+			subscriptionDetails = cache.subscriptionDetails
+			address = subscriptionDetails.address
+			if !address
+				return next(new Error('no address in subscriptionDetails at createAccount stage'))
+			if cache.userExists
+				logger.log {user_id: user._id, recurly_token_id}, "user already exists in recurly"
+				return next(null, cache)
+			logger.log {user_id: user._id, recurly_token_id}, "creating user in recurly"
+			requestBody = """
+			<account>
+				<account_code>#{user._id}</account_code>
+				<email>#{user.email}</email>
+				<first_name>#{user.first_name}</first_name>
+				<last_name>#{user.last_name}</last_name>
+				<address>
+					<address1>#{address.address1}</address1>
+					<address2>#{address.address2}</address2>
+					<city>#{address.city || ''}</city>
+					<state>#{address.state || ''}</state>
+					<zip>#{address.zip || ''}</zip>
+					<country>#{address.country}</country>
+				</address>
+			</account>
+			"""
+			RecurlyWrapper.apiRequest({
+				url    : "accounts"
+				method : "POST"
+				body   : requestBody
+			}, (error, response, responseBody) =>
+				if error
+					logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating account"
+					return next(error)
+				RecurlyWrapper._parseAccountXml responseBody, (err, account) ->
+					if err
+						logger.error {err, user_id: user._id, recurly_token_id}, "error creating account"
+						return next(err)
+					cache.account = account
+					return next(null, cache)
+			)
+		createBillingInfo: (cache, next) ->
+			user = cache.user
+			recurly_token_id = cache.recurly_token_id
+			subscriptionDetails = cache.subscriptionDetails
+			logger.log {user_id: user._id, recurly_token_id}, "creating billing info in recurly"
+			accountCode = cache?.account?.account_code
+			if !accountCode
+				return next(new Error('no account code at createBillingInfo stage'))
+			requestBody = """
+			<billing_info>
+				<token_id>#{recurly_token_id}</token_id>
+			</billing_info>
+			"""
+			RecurlyWrapper.apiRequest({
+				url: "accounts/#{accountCode}/billing_info"
+				method: "POST"
+				body: requestBody
+			}, (error, response, responseBody) =>
+				if error
+					logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating billing info"
+					return next(error)
+				RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) ->
+					if err
+						logger.error {err, user_id: user._id, accountCode, recurly_token_id}, "error creating billing info"
+						return next(err)
+					cache.billingInfo = billingInfo
+					return next(null, cache)
+			)
+
+		setAddress: (cache, next) ->
+			user = cache.user
+			recurly_token_id = cache.recurly_token_id
+			subscriptionDetails = cache.subscriptionDetails
+			logger.log {user_id: user._id, recurly_token_id}, "setting billing address in recurly"
+			accountCode = cache?.account?.account_code
+			if !accountCode
+				return next(new Error('no account code at setAddress stage'))
+			address = subscriptionDetails.address
+			if !address
+				return next(new Error('no address in subscriptionDetails at setAddress stage'))
+			requestBody = RecurlyWrapper._addressToXml(address)
+			RecurlyWrapper.apiRequest({
+				url: "accounts/#{accountCode}/billing_info"
+				method: "PUT"
+				body: requestBody
+			}, (error, response, responseBody) =>
+				if error
+					logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while setting address"
+					return next(error)
+				RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) ->
+					if err
+						logger.error {err, user_id: user._id, recurly_token_id}, "error updating billing info"
+						return next(err)
+					cache.billingInfo = billingInfo
+					return next(null, cache)
+			)
+		createSubscription: (cache, next) ->
+			user = cache.user
+			recurly_token_id = cache.recurly_token_id
+			subscriptionDetails = cache.subscriptionDetails
+			logger.log {user_id: user._id, recurly_token_id}, "creating subscription in recurly"
+			requestBody = """
+			<subscription>
+				<plan_code>#{subscriptionDetails.plan_code}</plan_code>
+				<currency>#{subscriptionDetails.currencyCode}</currency>
+				<coupon_code>#{subscriptionDetails.coupon_code}</coupon_code>
+				<account>
+					<account_code>#{user._id}</account_code>
+				</account>
+			</subscription>
+			"""  # TODO: check account details and billing
+			RecurlyWrapper.apiRequest({
+				url    : "subscriptions"
+				method : "POST"
+				body   : requestBody
+			}, (error, response, responseBody) =>
+				if error
+					logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating subscription"
+					return next(error)
+				RecurlyWrapper._parseSubscriptionXml responseBody, (err, subscription) ->
+					if err
+						logger.error {err, user_id: user._id, recurly_token_id}, "error creating subscription"
+						return next(err)
+					cache.subscription = subscription
+					return next(null, cache)
+			)
+
+	_createPaypalSubscription: (user, subscriptionDetails, recurly_token_id, callback) ->
+		logger.log {user_id: user._id, recurly_token_id}, "starting process of creating paypal subscription"
+		# We use `async.waterfall` to run each of these actions in sequence
+		# passing a `cache` object along the way. The cache is initialized
+		# with required data, and `async.apply` to pass the cache to the first function
+		cache = {user, recurly_token_id, subscriptionDetails}
+		Async.waterfall([
+			Async.apply(RecurlyWrapper._paypal.checkAccountExists, cache),
+			RecurlyWrapper._paypal.createAccount,
+			RecurlyWrapper._paypal.createBillingInfo,
+			RecurlyWrapper._paypal.setAddress,
+			RecurlyWrapper._paypal.createSubscription,
+		], (err, result) ->
+			if err
+				logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process"
+				return callback(err)
+			if !result.subscription
+				err = new Error('no subscription object in result')
+				logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process"
+				return callback(err)
+			logger.log {user_id: user._id, recurly_token_id}, "done creating paypal subscription for user"
+			callback(null, result.subscription)
+		)
+
+	_createCreditCardSubscription: (user, subscriptionDetails, recurly_token_id, callback) ->
 		requestBody = """
 		requestBody = """
 		          <subscription>
 		          <subscription>
 		            <plan_code>#{subscriptionDetails.plan_code}</plan_code>
 		            <plan_code>#{subscriptionDetails.plan_code}</plan_code>
@@ -25,17 +215,23 @@ module.exports = RecurlyWrapper =
 		            </account>
 		            </account>
 		          </subscription>
 		          </subscription>
 		          """
 		          """
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url    : "subscriptions"
 			url    : "subscriptions"
 			method : "POST"
 			method : "POST"
 			body   : requestBody
 			body   : requestBody
 		}, (error, response, responseBody) =>
 		}, (error, response, responseBody) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@_parseSubscriptionXml responseBody, callback
-		)		
+			RecurlyWrapper._parseSubscriptionXml responseBody, callback
+		)
+
+	createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
+		isPaypal = subscriptionDetails.isPaypal
+		logger.log {user_id: user._id, isPaypal, recurly_token_id}, "setting up subscription in recurly"
+		fn = if isPaypal then RecurlyWrapper._createPaypalSubscription else RecurlyWrapper._createCreditCardSubscription
+		fn user, subscriptionDetails, recurly_token_id, callback
 
 
 	apiRequest : (options, callback) ->
 	apiRequest : (options, callback) ->
-		options.url = @apiUrl + "/" + options.url
+		options.url = RecurlyWrapper.apiUrl + "/" + options.url
 		options.headers =
 		options.headers =
 			"Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64")
 			"Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64")
 			"Accept"        : "application/xml"
 			"Accept"        : "application/xml"
@@ -60,7 +256,7 @@ module.exports = RecurlyWrapper =
 						newAttributes[key] = value
 						newAttributes[key] = value
 				else
 				else
 					newAttributes[newKey] = value
 					newAttributes[newKey] = value
-					
+
 			return newAttributes
 			return newAttributes
 
 
 		crypto.randomBytes 32, (error, buffer) ->
 		crypto.randomBytes 32, (error, buffer) ->
@@ -74,14 +270,14 @@ module.exports = RecurlyWrapper =
 			signature = "#{signed}|#{unsignedQuery}"
 			signature = "#{signed}|#{unsignedQuery}"
 
 
 			callback null, signature
 			callback null, signature
-	
+
 
 
 	getSubscriptions: (accountId, callback)->
 	getSubscriptions: (accountId, callback)->
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url: "accounts/#{accountId}/subscriptions"
 			url: "accounts/#{accountId}/subscriptions"
 		}, (error, response, body) =>
 		}, (error, response, body) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@_parseXml body, callback
+			RecurlyWrapper._parseXml body, callback
 		)
 		)
 
 
 
 
@@ -94,11 +290,11 @@ module.exports = RecurlyWrapper =
 		else
 		else
 			url = "subscriptions/#{subscriptionId}"
 			url = "subscriptions/#{subscriptionId}"
 
 
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url: url
 			url: url
 		}, (error, response, body) =>
 		}, (error, response, body) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@_parseSubscriptionXml body, (error, recurlySubscription) =>
+			RecurlyWrapper._parseSubscriptionXml body, (error, recurlySubscription) =>
 				return callback(error) if error?
 				return callback(error) if error?
 				if options.includeAccount
 				if options.includeAccount
 					if recurlySubscription.account? and recurlySubscription.account.url?
 					if recurlySubscription.account? and recurlySubscription.account.url?
@@ -106,7 +302,7 @@ module.exports = RecurlyWrapper =
 					else
 					else
 						return callback "I don't understand the response from Recurly"
 						return callback "I don't understand the response from Recurly"
 
 
-					@getAccount accountId, (error, account) ->
+					RecurlyWrapper.getAccount accountId, (error, account) ->
 						return callback(error) if error?
 						return callback(error) if error?
 						recurlySubscription.account = account
 						recurlySubscription.account = account
 						callback null, recurlySubscription
 						callback null, recurlySubscription
@@ -124,9 +320,9 @@ module.exports = RecurlyWrapper =
 					per_page:200
 					per_page:200
 			if cursor?
 			if cursor?
 				opts.qs.cursor = cursor
 				opts.qs.cursor = cursor
-			@apiRequest opts, (error, response, body) =>
+			RecurlyWrapper.apiRequest opts, (error, response, body) =>
 				return callback(error) if error?
 				return callback(error) if error?
-				@_parseXml body, (err, data)->
+				RecurlyWrapper._parseXml body, (err, data)->
 					if err?
 					if err?
 						logger.err err:err, "could not get accoutns"
 						logger.err err:err, "could not get accoutns"
 						callback(err)
 						callback(err)
@@ -142,19 +338,19 @@ module.exports = RecurlyWrapper =
 
 
 
 
 	getAccount: (accountId, callback) ->
 	getAccount: (accountId, callback) ->
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url: "accounts/#{accountId}"
 			url: "accounts/#{accountId}"
 		}, (error, response, body) =>
 		}, (error, response, body) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@_parseAccountXml body, callback
+			RecurlyWrapper._parseAccountXml body, callback
 		)
 		)
 
 
 	getBillingInfo: (accountId, callback)->
 	getBillingInfo: (accountId, callback)->
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url: "accounts/#{accountId}/billing_info"
 			url: "accounts/#{accountId}/billing_info"
 		}, (error, response, body) =>
 		}, (error, response, body) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@_parseXml body, callback
+			RecurlyWrapper._parseXml body, callback
 		)
 		)
 
 
 
 
@@ -166,13 +362,13 @@ module.exports = RecurlyWrapper =
 		            <timeframe>#{options.timeframe}</timeframe>
 		            <timeframe>#{options.timeframe}</timeframe>
 		          </subscription>
 		          </subscription>
 		          """
 		          """
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url    : "subscriptions/#{subscriptionId}"
 			url    : "subscriptions/#{subscriptionId}"
 			method : "put"
 			method : "put"
 			body   : requestBody
 			body   : requestBody
 		}, (error, response, responseBody) =>
 		}, (error, response, responseBody) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@_parseSubscriptionXml responseBody, callback
+			RecurlyWrapper._parseSubscriptionXml responseBody, callback
 		)
 		)
 
 
 	createFixedAmmountCoupon: (coupon_code, name, currencyCode, discount_in_cents, plan_code, callback)->
 	createFixedAmmountCoupon: (coupon_code, name, currencyCode, discount_in_cents, plan_code, callback)->
@@ -191,7 +387,7 @@ module.exports = RecurlyWrapper =
 			</coupon>
 			</coupon>
 		"""
 		"""
 		logger.log coupon_code:coupon_code, requestBody:requestBody, "creating coupon"
 		logger.log coupon_code:coupon_code, requestBody:requestBody, "creating coupon"
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url    : "coupons"
 			url    : "coupons"
 			method : "post"
 			method : "post"
 			body   : requestBody
 			body   : requestBody
@@ -203,16 +399,16 @@ module.exports = RecurlyWrapper =
 
 
 
 
 	lookupCoupon: (coupon_code, callback)->
 	lookupCoupon: (coupon_code, callback)->
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url: "coupons/#{coupon_code}"
 			url: "coupons/#{coupon_code}"
 		}, (error, response, body) =>
 		}, (error, response, body) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@_parseXml body, callback
+			RecurlyWrapper._parseXml body, callback
 		)
 		)
 
 
 	cancelSubscription: (subscriptionId, callback) ->
 	cancelSubscription: (subscriptionId, callback) ->
 		logger.log subscriptionId:subscriptionId, "telling recurly to cancel subscription"
 		logger.log subscriptionId:subscriptionId, "telling recurly to cancel subscription"
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url: "subscriptions/#{subscriptionId}/cancel",
 			url: "subscriptions/#{subscriptionId}/cancel",
 			method: "put"
 			method: "put"
 		}, (error, response, body) ->
 		}, (error, response, body) ->
@@ -221,13 +417,13 @@ module.exports = RecurlyWrapper =
 
 
 	reactivateSubscription: (subscriptionId, callback) ->
 	reactivateSubscription: (subscriptionId, callback) ->
 		logger.log subscriptionId:subscriptionId, "telling recurly to reactivating subscription"
 		logger.log subscriptionId:subscriptionId, "telling recurly to reactivating subscription"
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url: "subscriptions/#{subscriptionId}/reactivate",
 			url: "subscriptions/#{subscriptionId}/reactivate",
 			method: "put"
 			method: "put"
 		}, (error, response, body) ->
 		}, (error, response, body) ->
 			callback(error)
 			callback(error)
 		)
 		)
-		
+
 
 
 	redeemCoupon: (account_code, coupon_code, callback)->
 	redeemCoupon: (account_code, coupon_code, callback)->
 		requestBody = """
 		requestBody = """
@@ -237,7 +433,7 @@ module.exports = RecurlyWrapper =
 			</redemption>
 			</redemption>
 		"""
 		"""
 		logger.log account_code:account_code, coupon_code:coupon_code, requestBody:requestBody, "redeeming coupon for user"
 		logger.log account_code:account_code, coupon_code:coupon_code, requestBody:requestBody, "redeeming coupon for user"
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url    : "coupons/#{coupon_code}/redeem"
 			url    : "coupons/#{coupon_code}/redeem"
 			method : "post"
 			method : "post"
 			body   : requestBody
 			body   : requestBody
@@ -251,7 +447,7 @@ module.exports = RecurlyWrapper =
 		next_renewal_date = new Date()
 		next_renewal_date = new Date()
 		next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire)
 		next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire)
 		logger.log subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "Exending Free trial for user"
 		logger.log subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "Exending Free trial for user"
-		@apiRequest({
+		RecurlyWrapper.apiRequest({
 			url    : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false"
 			url    : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false"
 			method : "put"
 			method : "put"
 		}, (error, response, responseBody) =>
 		}, (error, response, responseBody) =>
@@ -261,7 +457,7 @@ module.exports = RecurlyWrapper =
 		)
 		)
 
 
 	_parseSubscriptionXml: (xml, callback) ->
 	_parseSubscriptionXml: (xml, callback) ->
-		@_parseXml xml, (error, data) ->
+		RecurlyWrapper._parseXml xml, (error, data) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if data? and data.subscription?
 			if data? and data.subscription?
 				recurlySubscription = data.subscription
 				recurlySubscription = data.subscription
@@ -270,7 +466,7 @@ module.exports = RecurlyWrapper =
 			callback null, recurlySubscription
 			callback null, recurlySubscription
 
 
 	_parseAccountXml: (xml, callback) ->
 	_parseAccountXml: (xml, callback) ->
-		@_parseXml xml, (error, data) ->
+		RecurlyWrapper._parseXml xml, (error, data) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if data? and data.account?
 			if data? and data.account?
 				account = data.account
 				account = data.account
@@ -278,6 +474,15 @@ module.exports = RecurlyWrapper =
 				return callback "I don't understand the response from Recurly"
 				return callback "I don't understand the response from Recurly"
 			callback null, account
 			callback null, account
 
 
+	_parseBillingInfoXml: (xml, callback) ->
+		RecurlyWrapper._parseXml xml, (error, data) ->
+			return callback(error) if error?
+			if data? and data.billing_info?
+				billingInfo = data.billing_info
+			else
+				return callback "I don't understand the response from Recurly"
+			callback null, billingInfo
+
 	_parseXml: (xml, callback) ->
 	_parseXml: (xml, callback) ->
 		convertDataTypes = (data) ->
 		convertDataTypes = (data) ->
 			if data? and data["$"]?
 			if data? and data["$"]?
@@ -299,7 +504,7 @@ module.exports = RecurlyWrapper =
 						else
 						else
 							array.push(convertDataTypes(value))
 							array.push(convertDataTypes(value))
 					data = array
 					data = array
-			
+
 			if data instanceof Array
 			if data instanceof Array
 				data = (convertDataTypes(entry) for entry in data)
 				data = (convertDataTypes(entry) for entry in data)
 			else if typeof data == "object"
 			else if typeof data == "object"
@@ -315,6 +520,3 @@ module.exports = RecurlyWrapper =
 			return callback(error) if error?
 			return callback(error) if error?
 			result = convertDataTypes(data)
 			result = convertDataTypes(data)
 			callback null, result
 			callback null, result
-
-			
-

+ 4 - 3
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -82,9 +82,9 @@ module.exports = SubscriptionController =
 					logger.log user: user, "redirecting to plans"
 					logger.log user: user, "redirecting to plans"
 					res.redirect "/user/subscription/plans"
 					res.redirect "/user/subscription/plans"
 				else
 				else
-					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groups) ->
+					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions) ->
 						return next(error) if error?
 						return next(error) if error?
-						logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groups:groups, "showing subscription dashboard"
+						logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groupSubscriptions:groupSubscriptions, "showing subscription dashboard"
 						plans = SubscriptionViewModelBuilder.buildViewModel()
 						plans = SubscriptionViewModelBuilder.buildViewModel()
 						res.render "subscriptions/dashboard",
 						res.render "subscriptions/dashboard",
 							title: "your_subscription"
 							title: "your_subscription"
@@ -92,8 +92,9 @@ module.exports = SubscriptionController =
 							taxRate:subscription?.taxRate
 							taxRate:subscription?.taxRate
 							plans: plans
 							plans: plans
 							subscription: subscription || {}
 							subscription: subscription || {}
-							groups: groups
+							groupSubscriptions: groupSubscriptions
 							subscriptionTabActive: true
 							subscriptionTabActive: true
+							user:user
 
 
 
 
 	userCustomSubscriptionPage: (req, res, next)->
 	userCustomSubscriptionPage: (req, res, next)->

+ 2 - 1
services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee

@@ -8,7 +8,7 @@ _ = require("underscore")
 
 
 module.exports =
 module.exports =
 
 
-	buildUsersSubscriptionViewModel: (user, callback = (error, subscription, groups) ->) ->
+	buildUsersSubscriptionViewModel: (user, callback = (error, subscription, memberSubscriptions) ->) ->
 		SubscriptionLocator.getUsersSubscription user, (err, subscription) ->
 		SubscriptionLocator.getUsersSubscription user, (err, subscription) ->
 			return callback(err) if err?
 			return callback(err) if err?
 			SubscriptionLocator.getMemberSubscriptions user, (err, memberSubscriptions = []) ->
 			SubscriptionLocator.getMemberSubscriptions user, (err, memberSubscriptions = []) ->
@@ -19,6 +19,7 @@ module.exports =
 					RecurlyWrapper.getSubscription subscription.recurlySubscription_id, (err, recurlySubscription)->
 					RecurlyWrapper.getSubscription subscription.recurlySubscription_id, (err, recurlySubscription)->
 						tax = recurlySubscription?.tax_in_cents || 0
 						tax = recurlySubscription?.tax_in_cents || 0
 						callback null, {
 						callback null, {
+							admin_id:subscription.admin_id
 							name: plan.name
 							name: plan.name
 							nextPaymentDueAt: SubscriptionFormatters.formatDate(recurlySubscription?.current_period_ends_at)
 							nextPaymentDueAt: SubscriptionFormatters.formatDate(recurlySubscription?.current_period_ends_at)
 							state: recurlySubscription?.state
 							state: recurlySubscription?.state

+ 11 - 8
services/web/app/coffee/Features/User/UserController.coffee

@@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
 metrics = require("../../infrastructure/Metrics")
 metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
+UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
 UserUpdater = require("./UserUpdater")
 settings = require "settings-sharelatex"
 settings = require "settings-sharelatex"
 
 
@@ -72,6 +73,7 @@ module.exports = UserController =
 							if err?
 							if err?
 								logger.err err:err, user_id:user_id, "error getting user for email update"
 								logger.err err:err, user_id:user_id, "error getting user for email update"
 								return res.send 500
 								return res.send 500
+							req.session.user.email = user.email
 							UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
 							UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
 								if err?
 								if err?
 									logger.err err:err, "error populateGroupLicenceInvite"
 									logger.err err:err, "error populateGroupLicenceInvite"
@@ -80,9 +82,12 @@ module.exports = UserController =
 	logout : (req, res)->
 	logout : (req, res)->
 		metrics.inc "user.logout"
 		metrics.inc "user.logout"
 		logger.log user: req?.session?.user, "logging out"
 		logger.log user: req?.session?.user, "logging out"
+		sessionId = req.sessionID
+		user = req?.session?.user
 		req.session.destroy (err)->
 		req.session.destroy (err)->
 			if err
 			if err
 				logger.err err: err, 'error destorying session'
 				logger.err err: err, 'error destorying session'
+			UserSessionsManager.untrackSession(user, sessionId)
 			res.redirect '/login'
 			res.redirect '/login'
 
 
 	register : (req, res, next = (error) ->)->
 	register : (req, res, next = (error) ->)->
@@ -116,17 +121,15 @@ module.exports = UserController =
 					logger.log user: user, "password changed"
 					logger.log user: user, "password changed"
 					AuthenticationManager.setUserPassword user._id, newPassword1, (error) ->
 					AuthenticationManager.setUserPassword user._id, newPassword1, (error) ->
 						return next(error) if error?
 						return next(error) if error?
-						res.send
-							message:
-							  type:'success'
-							  text:'Your password has been changed'
+						UserSessionsManager.revokeAllUserSessions user, [req.sessionID], (err) ->
+							return next(err) if err?
+							res.send
+								message:
+									type:'success'
+									text:'Your password has been changed'
 			else
 			else
 				logger.log user: user, "current password wrong"
 				logger.log user: user, "current password wrong"
 				res.send
 				res.send
 					message:
 					message:
 					  type:'error'
 					  type:'error'
 					  text:'Your old password is wrong'
 					  text:'Your old password is wrong'
-
-	changeEmailAddress: (req, res)->
-
-

+ 10 - 2
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -1,5 +1,6 @@
 User = require("../../models/User").User
 User = require("../../models/User").User
 UserLocator = require("./UserLocator")
 UserLocator = require("./UserLocator")
+logger = require("logger-sharelatex")
 
 
 module.exports =
 module.exports =
 
 
@@ -12,16 +13,23 @@ module.exports =
 				self.createNewUser email:email, holdingAccount:true, callback
 				self.createNewUser email:email, holdingAccount:true, callback
 
 
 	createNewUser: (opts, callback)->
 	createNewUser: (opts, callback)->
+		logger.log opts:opts, "creating new user"
 		user = new User()
 		user = new User()
 		user.email = opts.email
 		user.email = opts.email
 		user.holdingAccount = opts.holdingAccount
 		user.holdingAccount = opts.holdingAccount
 
 
 		username = opts.email.match(/^[^@]*/)
 		username = opts.email.match(/^[^@]*/)
-		if username?
+		if opts.first_name? and opts.first_name.length != 0
+			user.first_name = opts.first_name
+		else if username?
 			user.first_name = username[0]
 			user.first_name = username[0]
 		else
 		else
 			user.first_name = ""
 			user.first_name = ""
-		user.last_name = ""
+
+		if opts.last_name?
+			user.last_name = opts.last_name
+		else
+			user.last_name = ""
 
 
 		user.featureSwitches?.pdfng = true
 		user.featureSwitches?.pdfng = true
 
 

+ 1 - 1
services/web/app/coffee/Features/User/UserHandler.coffee

@@ -6,7 +6,7 @@ logger = require("logger-sharelatex")
 
 
 module.exports = UserHandler =
 module.exports = UserHandler =
 
 
-	populateGroupLicenceInvite: (user, callback)->
+	populateGroupLicenceInvite: (user, callback = ->)->
 		logger.log user_id:user._id, "populating any potential group licence invites"
 		logger.log user_id:user._id, "populating any potential group licence invites"
 		licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
 		licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
 		if !licence?
 		if !licence?

+ 2 - 0
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -26,6 +26,7 @@ module.exports =
 	activateAccountPage: (req, res) ->
 	activateAccountPage: (req, res) ->
 		# An 'activation' is actually just a password reset on an account that
 		# An 'activation' is actually just a password reset on an account that
 		# was set with a random password originally.
 		# was set with a random password originally.
+		logger.log query:req.query, "activiate account page called"
 		if !req.query?.user_id? or !req.query?.token?
 		if !req.query?.user_id? or !req.query?.token?
 			return ErrorController.notFound(req, res)
 			return ErrorController.notFound(req, res)
 			
 			
@@ -34,6 +35,7 @@ module.exports =
 			if !user
 			if !user
 				return ErrorController.notFound(req, res)
 				return ErrorController.notFound(req, res)
 			if user.loginCount > 0
 			if user.loginCount > 0
+				logger.log user:user, "user has already logged in so is active, sending them to /login"
 				# Already seen this user, so account must be activate
 				# Already seen this user, so account must be activate
 				# This lets users keep clicking the 'activate' link in their email
 				# This lets users keep clicking the 'activate' link in their email
 				# as a way to log in which, if I know our users, they will.
 				# as a way to log in which, if I know our users, they will.

+ 2 - 1
services/web/app/coffee/Features/User/UserRegistrationHandler.coffee

@@ -35,7 +35,8 @@ module.exports = UserRegistrationHandler =
 
 
 	_createNewUserIfRequired: (user, userDetails, callback)->
 	_createNewUserIfRequired: (user, userDetails, callback)->
 		if !user?
 		if !user?
-			UserCreator.createNewUser {holdingAccount:false, email:userDetails.email}, callback
+			userDetails.holdingAccount = false
+			UserCreator.createNewUser {holdingAccount:false, email:userDetails.email, first_name:userDetails.first_name, last_name:userDetails.last_name}, callback
 		else
 		else
 			callback null, user
 			callback null, user
 
 

+ 126 - 0
services/web/app/coffee/Features/User/UserSessionsManager.coffee

@@ -0,0 +1,126 @@
+Settings = require('settings-sharelatex')
+redis = require('redis-sharelatex')
+logger = require("logger-sharelatex")
+Async = require('async')
+_ = require('underscore')
+
+rclient = redis.createClient(Settings.redis.web)
+
+module.exports = UserSessionsManager =
+
+	_sessionSetKey: (user) ->
+		return "UserSessions:#{user._id}"
+
+	# mimic the key used by the express sessions
+	_sessionKey: (sessionId) ->
+		return "sess:#{sessionId}"
+
+	trackSession: (user, sessionId, callback=(err)-> ) ->
+		if !user?
+			logger.log {sessionId}, "no user to track, returning"
+			return callback(null)
+		if !sessionId?
+			logger.log {user_id: user._id}, "no sessionId to track, returning"
+			return callback(null)
+		logger.log {user_id: user._id, sessionId}, "onLogin handler"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		value = UserSessionsManager._sessionKey sessionId
+		rclient.multi()
+			.sadd(sessionSetKey, value)
+			.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+			.exec (err, response) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "error while adding session key to UserSessions set"
+					return callback(err)
+				UserSessionsManager._checkSessions(user, () ->)
+				callback()
+
+	untrackSession: (user, sessionId, callback=(err)-> ) ->
+		if !user?
+			logger.log {sessionId}, "no user to untrack, returning"
+			return callback(null)
+		if !sessionId?
+			logger.log {user_id: user._id}, "no sessionId to untrack, returning"
+			return callback(null)
+		logger.log {user_id: user._id, sessionId}, "onLogout handler"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		value = UserSessionsManager._sessionKey sessionId
+		rclient.multi()
+			.srem(sessionSetKey, value)
+			.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+			.exec (err, response) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "error while removing session key from UserSessions set"
+					return callback(err)
+				UserSessionsManager._checkSessions(user, () ->)
+				callback()
+
+	revokeAllUserSessions: (user, retain, callback=(err)->) ->
+		if !retain
+			retain = []
+		retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
+		if !user
+			logger.log {}, "no user to revoke sessions for, returning"
+			return callback(null)
+		logger.log {user_id: user._id}, "revoking all existing sessions for user"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+				return callback(err)
+			keysToDelete = _.filter(sessionKeys, (k) -> k not in retain)
+			if keysToDelete.length == 0
+				logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
+				return callback(null)
+			logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
+			rclient.multi()
+				.del(keysToDelete)
+				.srem(sessionSetKey, keysToDelete)
+				.exec (err, result) ->
+					if err?
+						logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
+						return callback(err)
+					callback(null)
+
+	touch: (user, callback=(err)->) ->
+		if !user
+			logger.log {}, "no user to touch sessions for, returning"
+			return callback(null)
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
+			if err?
+				logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
+				return callback(err)
+			callback(null)
+
+	_checkSessions: (user, callback=(err)->) ->
+		if !user
+			logger.log {}, "no user, returning"
+			return callback(null)
+		logger.log {user_id: user._id}, "checking sessions for user"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+				return callback(err)
+			logger.log {user_id: user._id, count: sessionKeys.length}, "checking sessions for user"
+			Async.series(
+				sessionKeys.map(
+					(key) ->
+						(next) ->
+							rclient.get key, (err, val) ->
+								if err?
+									return next(err)
+								if !val?
+									logger.log {user_id: user._id, key}, ">> removing key from UserSessions set"
+									rclient.srem sessionSetKey, key, (err, result) ->
+										if err?
+											return next(err)
+										return next(null)
+								else
+									next()
+				)
+				, (err, results) ->
+					logger.log {user_id: user._id}, "done checking sessions for user"
+					return callback(err)
+			)

+ 0 - 81
services/web/app/coffee/Features/Wiki/WikiController.coffee

@@ -1,81 +0,0 @@
-request = require("request")
-settings = require("settings-sharelatex")
-logger = require("logger-sharelatex")
-ErrorController = require "../Errors/ErrorController"
-_ = require("underscore")
-AuthenticationController = require("../Authentication/AuthenticationController")
-async = require("async")
-other_lngs = ["es"]
-
-module.exports = WikiController = 
-
-
-	_checkIfLoginIsNeeded: (req, res, next)->
-		if settings.apis.wiki.requireLogin
-			AuthenticationController.requireLogin()(req, res, next)
-		else
-			next()
-
-	getPage: (req, res, next) ->
-		WikiController._checkIfLoginIsNeeded req, res, ->
-			
-			page = req.url.replace(/^\/learn/, "").replace(/^\//, "")
-			if page == ""
-				page = "Main_Page"
-
-			logger.log page: page, "getting page from wiki"
-			if _.include(other_lngs, req.lng)
-				lngPage = "#{page}_#{req.lng}"
-			else
-				lngPage = page
-			jobs =
-				contents: (cb)-> 
-					WikiController._getPageContent "Contents", cb
-				pageData: (cb)->
-					WikiController._getPageContent lngPage, cb
-			async.parallel jobs, (error, results)->
-				return next(error) if error?
-				{pageData, contents} = results
-				if pageData.content?.length > 280
-					if _.include(other_lngs, req.lng)
-						pageData.title = pageData.title.slice(0, pageData.title.length - (req.lng.length+1) )
-					WikiController._renderPage(pageData, contents, res)
-				else
-					WikiController._getPageContent page, (error, pageData) ->
-						return next(error) if error?
-						WikiController._renderPage(pageData, contents, res)
-
-
-					
-
-	_getPageContent: (page, callback = (error, data = { content: "", title: "" }) ->) ->
-		request {
-			url: "#{settings.apis.wiki.url}/learn-scripts/api.php"
-			qs: {
-				page: decodeURI(page)
-				action: "parse"
-				format: "json"
-			}
-		}, (err, response, data)->
-			return callback(err) if err?
-			try
-				data = JSON.parse(data)
-			catch err
-				logger.err err:err, data:data, "error parsing data from wiki"
-			result = 
-				content: data?.parse?.text?['*']
-				title: data?.parse?.title
-			callback null, result
-
-
-	_renderPage: (page, contents, res)->
-		if page.title == "Main Page"
-			title = "Documentation"
-		else
-			title = page.title
-			
-		res.render "wiki/page", {
-			page: page
-			contents: contents
-			title: title
-		}

+ 63 - 7
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -7,15 +7,19 @@ querystring = require('querystring')
 SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
 SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
 _ = require("underscore")
 _ = require("underscore")
 Modules = require "./Modules"
 Modules = require "./Modules"
+Url = require "url"
 
 
 fingerprints = {}
 fingerprints = {}
 Path = require 'path'
 Path = require 'path'
+
+
 jsPath =
 jsPath =
 	if Settings.useMinifiedJs
 	if Settings.useMinifiedJs
 		"/minjs/"
 		"/minjs/"
 	else
 	else
 		"/js/"
 		"/js/"
 
 
+
 logger.log "Generating file fingerprints..."
 logger.log "Generating file fingerprints..."
 for path in [
 for path in [
 	"#{jsPath}libs/require.js",
 	"#{jsPath}libs/require.js",
@@ -37,7 +41,18 @@ for path in [
 		fingerprints[path] = hash
 		fingerprints[path] = hash
 	else
 	else
 		logger.log filePath:filePath, "file does not exist for fingerprints"
 		logger.log filePath:filePath, "file does not exist for fingerprints"
-	
+
+getFingerprint = (path) ->
+	if fingerprints[path]?
+		return fingerprints[path]
+	else
+		logger.err "No fingerprint for file: #{path}"
+		return ""
+
+logger.log "Finished generating file fingerprints"
+
+cdnAvailable = Settings.cdn?.web?.host?
+darkCdnAvailable = Settings.cdn?.web?.darkHost?
 
 
 module.exports = (app, webRouter, apiRouter)->
 module.exports = (app, webRouter, apiRouter)->
 	webRouter.use (req, res, next)->
 	webRouter.use (req, res, next)->
@@ -45,9 +60,55 @@ module.exports = (app, webRouter, apiRouter)->
 		next()
 		next()
 
 
 	webRouter.use (req, res, next)-> 
 	webRouter.use (req, res, next)-> 
+
+		isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark"
+		isSmoke = req.headers?.host?.slice(0,5)?.toLowerCase() == "smoke"
+		isLive = !isDark and !isSmoke
+
+		if cdnAvailable and isLive
+			staticFilesBase = Settings.cdn?.web?.host
+		else if darkCdnAvailable and isDark
+			staticFilesBase = Settings.cdn?.web?.darkHost
+		else
+			staticFilesBase = ""
+		
 		res.locals.jsPath = jsPath
 		res.locals.jsPath = jsPath
+		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
+
+
+		res.locals.buildJsPath = (jsFile, opts = {})->
+			path = Path.join(jsPath, jsFile)
+
+			doFingerPrint = opts.fingerprint != false
+			
+			if !opts.qs?
+				opts.qs = {}
+
+			if !opts.qs?.fingerprint? and doFingerPrint
+				opts.qs.fingerprint = getFingerprint(path)
+
+			if opts.cdn != false
+				path = Url.resolve(staticFilesBase, path)
+				
+			qs = querystring.stringify(opts.qs)
+
+			if qs? and qs.length > 0
+				path = path + "?" + qs
+			return path
+
+
+		res.locals.buildCssPath = (cssFile)->
+			path = Path.join("/stylesheets/", cssFile)
+			return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path)
+
+		res.locals.buildImgPath = (imgFile)->
+			path = Path.join("/img/", imgFile)
+			return Url.resolve(staticFilesBase, path)
+
 		next()
 		next()
 
 
+
+
 	webRouter.use (req, res, next)-> 
 	webRouter.use (req, res, next)-> 
 		res.locals.settings = Settings
 		res.locals.settings = Settings
 		next()
 		next()
@@ -113,12 +174,7 @@ module.exports = (app, webRouter, apiRouter)->
 		next()
 		next()
 
 
 	webRouter.use (req, res, next)-> 
 	webRouter.use (req, res, next)-> 
-		res.locals.fingerprint = (path) ->
-			if fingerprints[path]?
-				return fingerprints[path]
-			else
-				logger.err "No fingerprint for file: #{path}"
-				return ""
+		res.locals.fingerprint = getFingerprint
 		next()
 		next()
 
 
 	webRouter.use (req, res, next)-> 
 	webRouter.use (req, res, next)-> 

+ 10 - 7
services/web/app/coffee/infrastructure/Server.coffee

@@ -31,6 +31,7 @@ translations = require("translations-sharelatex").setup(Settings.i18n)
 Modules = require "./Modules"
 Modules = require "./Modules"
 
 
 ErrorController = require "../Features/Errors/ErrorController"
 ErrorController = require "../Features/Errors/ErrorController"
+UserSessionsManager = require "../Features/User/UserSessionsManager"
 
 
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
@@ -89,6 +90,8 @@ webRouter.use translations.setLangBasedOnDomainMiddlewear
 # Measure expiry from last request, not last login
 # Measure expiry from last request, not last login
 webRouter.use (req, res, next) ->
 webRouter.use (req, res, next) ->
 	req.session.touch()
 	req.session.touch()
+	if req?.session?.user?
+		UserSessionsManager.touch(req.session.user, (err)->)
 	next()
 	next()
 
 
 webRouter.use ReferalConnect.use
 webRouter.use ReferalConnect.use
@@ -98,23 +101,23 @@ if app.get('env') == 'production'
 	logger.info "Production Enviroment"
 	logger.info "Production Enviroment"
 	app.enable('view cache')
 	app.enable('view cache')
 
 
-
-
 app.use (req, res, next)->
 app.use (req, res, next)->
 	metrics.inc "http-request"
 	metrics.inc "http-request"
 	crawlerLogger.log(req)
 	crawlerLogger.log(req)
 	next()
 	next()
 
 
-app.use (req, res, next) ->
-	if !Settings.editorIsOpen
+webRouter.use (req, res, next) ->
+	if Settings.editorIsOpen
+		next()
+	else if req.url.indexOf("/admin") == 0
+		next()
+	else
 		res.status(503)
 		res.status(503)
 		res.render("general/closed", {title:"maintenance"})
 		res.render("general/closed", {title:"maintenance"})
-	else
-		next()
 
 
 apiRouter.get "/status", (req, res)->
 apiRouter.get "/status", (req, res)->
 	res.send("web sharelatex is alive")
 	res.send("web sharelatex is alive")
-	
+
 profiler = require "v8-profiler"
 profiler = require "v8-profiler"
 apiRouter.get "/profile", (req, res) ->
 apiRouter.get "/profile", (req, res) ->
 	time = parseInt(req.query.time || "1000")
 	time = parseInt(req.query.time || "1000")

+ 1 - 0
services/web/app/coffee/models/User.coffee

@@ -58,6 +58,7 @@ UserSchema = new Schema
 		mendeley: Boolean  # coerce the refProviders values to Booleans
 		mendeley: Boolean  # coerce the refProviders values to Booleans
 		zotero: Boolean
 		zotero: Boolean
 	}
 	}
+	betaProgram:   { type:Boolean, default: false}
 
 
 conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: 10)
 conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: 10)
 
 

+ 40 - 12
services/web/app/coffee/router.coffee

@@ -30,7 +30,6 @@ PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
 ChatController = require("./Features/Chat/ChatController")
 BlogController = require("./Features/Blog/BlogController")
 BlogController = require("./Features/Blog/BlogController")
-WikiController = require("./Features/Wiki/WikiController")
 Modules = require "./infrastructure/Modules"
 Modules = require "./infrastructure/Modules"
 RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
 RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
 RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
 RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
@@ -38,6 +37,7 @@ InactiveProjectController = require("./Features/InactiveData/InactiveProjectCont
 ContactRouter = require("./Features/Contacts/ContactRouter")
 ContactRouter = require("./Features/Contacts/ContactRouter")
 ReferencesController = require('./Features/References/ReferencesController')
 ReferencesController = require('./Features/References/ReferencesController')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
+BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -79,6 +79,7 @@ module.exports = class Router
 		webRouter.get '/blog/*', BlogController.getPage
 		webRouter.get '/blog/*', BlogController.getPage
 
 
 		webRouter.get '/user/activate', UserPagesController.activateAccountPage
 		webRouter.get '/user/activate', UserPagesController.activateAccountPage
+		AuthenticationController.addEndpointToLoginWhitelist '/user/activate'
 
 
 		webRouter.get  '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
 		webRouter.get  '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
@@ -104,7 +105,11 @@ module.exports = class Router
 		webRouter.post '/project/:Project_id/settings/admin', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.updateProjectAdminSettings
 		webRouter.post '/project/:Project_id/settings/admin', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.updateProjectAdminSettings
 
 
 		webRouter.post '/project/:Project_id/compile', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.compile
 		webRouter.post '/project/:Project_id/compile', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.compile
-		webRouter.get  '/Project/:Project_id/output/output.pdf', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.downloadPdf
+		webRouter.post '/project/:Project_id/compile/stop', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.stopCompile
+
+		# Used by the web download buttons, adds filename header
+		webRouter.get  '/project/:Project_id/output/output.pdf', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.downloadPdf
+		# Used by the pdf viewers
 		webRouter.get  /^\/project\/([^\/]*)\/output\/(.*)$/,
 		webRouter.get  /^\/project\/([^\/]*)\/output\/(.*)$/,
 			((req, res, next) ->
 			((req, res, next) ->
 				params =
 				params =
@@ -118,12 +123,36 @@ module.exports = class Router
 			((req, res, next) ->
 			((req, res, next) ->
 				params =
 				params =
 					"Project_id": req.params[0]
 					"Project_id": req.params[0]
-					"build":      req.params[1]
+					"build_id":   req.params[1]
+					"file":       req.params[2]
+				req.params = params
+				next()
+			), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
+
+		# direct url access to output files for user but no build, to retrieve files when build fails
+		webRouter.get  /^\/project\/([^\/]*)\/user\/([0-9a-f-]+)\/output\/(.*)$/,
+			((req, res, next) ->
+				params =
+					"Project_id": req.params[0]
+					"user_id":   req.params[1]
 					"file":       req.params[2]
 					"file":       req.params[2]
 				req.params = params
 				req.params = params
 				next()
 				next()
 			), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
 			), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
 
 
+		# direct url access to output files for a specific user and build (query string not required)
+		webRouter.get  /^\/project\/([^\/]*)\/user\/([0-9a-f]+)\/build\/([0-9a-f-]+)\/output\/(.*)$/,
+			((req, res, next) ->
+				params =
+					"Project_id": req.params[0]
+					"user_id":    req.params[1]
+					"build_id":   req.params[2]
+					"file":       req.params[3]
+				req.params = params
+				next()
+			), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
+
+
 		webRouter.delete "/project/:Project_id/output", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.deleteAuxFiles
 		webRouter.delete "/project/:Project_id/output", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.deleteAuxFiles
 		webRouter.get "/project/:Project_id/sync/code", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncCode
 		webRouter.get "/project/:Project_id/sync/code", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncCode
 		webRouter.get "/project/:Project_id/sync/pdf", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncPdf
 		webRouter.get "/project/:Project_id/sync/pdf", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncPdf
@@ -187,16 +216,13 @@ module.exports = class Router
 		webRouter.get  "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
 		webRouter.get  "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
 		webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
 		webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
 
 
-		webRouter.get  /learn(\/.*)?/, RateLimiterMiddlewear.rateLimit({
-			endpointName: "wiki"
-			params: []
-			maxRequests: 60
-			timeInterval: 60
-		}), WikiController.getPage
-
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
 
 
+		webRouter.get "/beta/participate",  AuthenticationController.requireLogin(), BetaProgramController.optInPage
+		webRouter.post "/beta/opt-in", AuthenticationController.requireLogin(), BetaProgramController.optIn
+		webRouter.post "/beta/opt-out", AuthenticationController.requireLogin(), BetaProgramController.optOut
+
 		#Admin Stuff
 		#Admin Stuff
 		webRouter.get  '/admin', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.index
 		webRouter.get  '/admin', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.index
 		webRouter.get  '/admin/user', AuthorizationMiddlewear.ensureUserIsSiteAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon
 		webRouter.get  '/admin/user', AuthorizationMiddlewear.ensureUserIsSiteAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon
@@ -216,9 +242,11 @@ module.exports = class Router
 		apiRouter.get '/status', (req,res)->
 		apiRouter.get '/status', (req,res)->
 			res.send("websharelatex is up")
 			res.send("websharelatex is up")
 
 
+		webRouter.get '/dev/csrf', (req, res) ->
+			res.send res.locals.csrfToken
 
 
-		webRouter.get '/health_check', HealthCheckController.check
-		webRouter.get '/health_check/redis', HealthCheckController.checkRedis
+		apiRouter.get '/health_check', HealthCheckController.check
+		apiRouter.get '/health_check/redis', HealthCheckController.checkRedis
 
 
 		apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
 		apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
 			sendRes = _.once (statusCode, message)->
 			sendRes = _.once (statusCode, message)->

+ 18 - 15
services/web/app/views/admin/index.jade

@@ -9,6 +9,22 @@ block content
 						.page-header
 						.page-header
 							h1 Admin Panel
 							h1 Admin Panel
 						tabset(ng-cloak)
 						tabset(ng-cloak)
+							tab(heading="System Messages")
+								each message in systemMessages 
+									.alert.alert-info.row-spaced !{message.content}
+								hr
+								form(enctype='multipart/form-data', method='post', action='/admin/messages')
+									input(name="_csrf", type="hidden", value=csrfToken)
+									.form-group
+										label(for="content")
+										input.form-control(name="content", type="text", placeholder="Message...", required)
+									button.btn.btn-primary(type="submit") Post Message
+								hr
+								form(enctype='multipart/form-data', method='post', action='/admin/messages/clear')
+									input(name="_csrf", type="hidden", value=csrfToken)
+									button.btn.btn-danger(type="submit") Clear all messages
+
+
 							tab(heading="Open Sockets")
 							tab(heading="Open Sockets")
 								.row-spaced
 								.row-spaced
 									ul
 									ul
@@ -17,7 +33,7 @@ block content
 												ul
 												ul
 													-each agent in agents
 													-each agent in agents
 														li #{agent}
 														li #{agent}
-														
+
 							tab(heading="Close Editor")
 							tab(heading="Close Editor")
 								.row-spaced
 								.row-spaced
 									form(enctype='multipart/form-data', method='post',action='/admin/closeEditor')
 									form(enctype='multipart/form-data', method='post',action='/admin/closeEditor')
@@ -66,19 +82,6 @@ block content
 										.form-group
 										.form-group
 											button.btn-primary.btn(type='submit') Poll
 											button.btn-primary.btn(type='submit') Poll
 										
 										
-							tab(heading="System Messages")
-								each message in systemMessages 
-									.alert.alert-info.row-spaced !{message.content}
-								hr
-								form(enctype='multipart/form-data', method='post', action='/admin/messages')
-									input(name="_csrf", type="hidden", value=csrfToken)
-									.form-group
-										label(for="content")
-										input.form-control(name="content", type="text", placeholder="Message...", required)
-									button.btn.btn-primary(type="submit") Post Message
-								hr
-								form(enctype='multipart/form-data', method='post', action='/admin/messages/clear')
-									input(name="_csrf", type="hidden", value=csrfToken)
-									button.btn.btn-danger(type="submit") Clear all messages
+
 
 
 
 

+ 47 - 0
services/web/app/views/beta_program/opt_in.jade

@@ -0,0 +1,47 @@
+extends ../layout
+
+block content
+	.content.content-alt
+		.container.beta-opt-in-wrapper
+			.row
+				.col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2
+					.card
+						.page-header.text-centered
+							h1 
+								| #{translate("sharelatex_beta_program")}
+						.beta-opt-in
+							.container-fluid
+								.row
+									.col-md-12
+										p.text-centered #{translate("beta_program_benefits")}
+										p.text-centered
+											| #{translate("beta_program_badge_description")}
+											span.beta-feature-badge
+										p.text-centered
+											| #{translate("beta_program_current_beta_features_description")}
+											ul.list-unstyled.text-center
+												li 
+													i.fa.fa-fw.fa-book
+													| &nbsp;#{translate("mendeley_integration")}
+								.row.text-centered
+									.col-md-12
+										if user.betaProgram
+											p #{translate("beta_program_already_participating")}
+											form(id="beta-program-opt-out", method="post", action="/beta/opt-out", novalidate)
+												.form-group
+													input(type="hidden", name="_csrf", value=csrfToken)
+													button.btn.btn-primary(
+														type="submit"
+													)
+														span #{translate("beta_program_opt_out_action")}
+												.form-group
+													a(href="/project").btn.btn-info #{translate("back_to_your_projects")}
+										else
+											form(id="beta-program-opt-in", method="post", action="/beta/opt-in", novalidate)
+												.form-group
+													input(type="hidden", name="_csrf", value=csrfToken)
+													button.btn.btn-primary(
+														type="submit"
+													)
+														span #{translate("beta_program_opt_in_action")}
+											

+ 14 - 2
services/web/app/views/contact-us-modal.jade

@@ -11,7 +11,19 @@ script(type='text/ng-template', id='supportModalTemplate')
 			label
 			label
 				| #{translate("subject")}
 				| #{translate("subject")}
 			.form-group
 			.form-group
-				input.field.text.medium.span8.form-control(ng-model="form.subject", maxlength='255', tabindex='1', onkeyup='')
+				input.field.text.medium.span8.form-control(
+					ng-model="form.subject",
+					ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
+					maxlength='255',
+					tabindex='1',
+					onkeyup='')
+			.contact-suggestions(ng-show="suggestions.length")
+				p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>__kb__</a>", kb: translate("knowledge_base") })} 
+				ul.contact-suggestion-list
+					li(ng-repeat="suggestion in suggestions")
+						a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
+							span(ng-bind-html="suggestion.name")
+							i.fa.fa-angle-right
 			label.desc(ng-show="'#{getUserEmail()}'.length < 1")
 			label.desc(ng-show="'#{getUserEmail()}'.length < 1")
 				| #{translate("email")}
 				| #{translate("email")}
 			.form-group(ng-show="'#{getUserEmail()}'.length < 1")
 			.form-group(ng-show="'#{getUserEmail()}'.length < 1")
@@ -21,7 +33,7 @@ script(type='text/ng-template', id='supportModalTemplate')
 			.form-group
 			.form-group
 				input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
 				input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
 			label.desc
 			label.desc
-				| #{translate("suggestion")}
+				| #{translate("contact_message_label")}
 			.form-group
 			.form-group
 				textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='')
 				textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='')
 			.form-group.text-center
 			.form-group.text-center

+ 2 - 2
services/web/app/views/general/500.jade

@@ -3,7 +3,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 	head
 	head
 		title Something went wrong
 		title Something went wrong
 		link(rel="icon", href="/favicon.ico")
 		link(rel="icon", href="/favicon.ico")
-		link(rel='stylesheet', href='/stylesheets/style.css')
+		link(rel='stylesheet', href=buildCssPath('/style.css'))
 		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
 		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
 	body
 	body
 		.content
 		.content
@@ -12,7 +12,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 					.col-md-8.col-md-offset-2.text-center
 					.col-md-8.col-md-offset-2.text-center
 						.page-header
 						.page-header
 							h2 Oh dear, something went wrong.
 							h2 Oh dear, something went wrong.
-						p: img(src="/img/lion-sad-128.png", alt="Sad Lion")
+						p: img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
 						p
 						p
 							| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
 							| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
 						p
 						p

+ 83 - 13
services/web/app/views/layout.jade

@@ -13,13 +13,12 @@ html(itemscope, itemtype='http://schema.org/Product')
 		
 		
 
 
 		-if (typeof(title) == "undefined")
 		-if (typeof(title) == "undefined")
-			title ShareLaTeX, the Online LaTeX Editor
+			title= 'ShareLaTeX, '+ translate("online_latex_editor") 
 		-else
 		-else
-			title= translate(title) + ' - ShareLaTeX, '+translate("online_latex_editor") 
+			title= translate(title) + ' - ShareLaTeX, ' + translate("online_latex_editor") 
 
 
 		link(rel="icon", href="/favicon.ico")
 		link(rel="icon", href="/favicon.ico")
-		link(rel='stylesheet', href='/stylesheets/style.css?fingerprint='+fingerprint('/stylesheets/style.css'))
-		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
+		link(rel='stylesheet', href=buildCssPath('/style.css'))
 
 
 		if settings.i18n.subdomainLang
 		if settings.i18n.subdomainLang
 			each subdomainDetails in settings.i18n.subdomainLang
 			each subdomainDetails in settings.i18n.subdomainLang
@@ -30,7 +29,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
 		meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
 		
 		
 		-if (typeof(meta) == "undefined")
 		-if (typeof(meta) == "undefined")
-			meta(itemprop="description", name="description", content="An online LaTeX editor that's easy to use. No installation, real-time collaboration, version control, hundreds of LaTeX templates, and more.")
+			meta(itemprop="description", name="description", content='#{translate("site_description")}')
 		-else
 		-else
 			meta(itemprop="description", name="description" , content=meta)
 			meta(itemprop="description", name="description" , content=meta)
 		
 		
@@ -46,15 +45,84 @@ html(itemscope, itemtype='http://schema.org/Product')
 				ga('send', 'pageview');
 				ga('send', 'pageview');
 		- else
 		- else
 			script(type='text/javascript').
 			script(type='text/javascript').
-				window.ga = function() { console.log("Sending to GA", arguments) };
+				window.ga = function() { console.log("would send to GA", arguments) };
 
 
+		// Countly Analytics
+		if (settings.analytics && settings.analytics.countly && settings.analytics.countly.token)
+			script(type="text/javascript").
+				var Countly = Countly || {};
+				Countly.q = Countly.q || [];
+				Countly.app_key = '#{settings.analytics.countly.token}';
+				Countly.url = '#{settings.analytics.countly.server}';
+				!{ session.user ? 'Countly.device_id = "' + session.user._id + '";' : '' } 
+
+				(function() {
+					var cly = document.createElement('script'); cly.type = 'text/javascript';
+					cly.async = true;
+					//enter url of script here
+					cly.src = 'https://cdnjs.cloudflare.com/ajax/libs/countly-sdk-web/16.6.0/countly.min.js';
+					cly.onload = function(){Countly.init()};
+					var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(cly, s);
+				})();
+
+			script(type="text/javascript")
+				if (session && session.user)
+					- var name = session.user.first_name + (session.user.last_name ? ' ' + session.user.last_name : '');
+					| Countly.q.push(['user_details', { email: '#{session.user.email}', name: '#{name}' }]);
+
+				if (justRegistered)
+					| Countly.q.push(['add_event',{ key: 'user-registered' }]);
+
+				if (justLoggedIn)
+					| Countly.q.push(['add_event',{ key: 'user-logged-in' }]);
+
+				if (user && user.features) 
+					- featureFlagSet = false;
+
+					if user.features.hasOwnProperty('collaborators')
+						| Countly.q.push([ 'userData.set', 'features-collaborators', #{ user.features.collaborators } ]);
+						- featureFlagSet = true;
+
+					if user.features.hasOwnProperty('compileGroup')
+						| Countly.q.push([ 'userData.set', 'features-compileGroup', '#{ user.features.compileGroup }' ]);
+						- featureFlagSet = true;
+
+					if user.features.hasOwnProperty('compileTimeout')
+						| Countly.q.push([ 'userData.set', 'features-compileTimeout', #{ user.features.compileTimeout } ]);
+						- featureFlagSet = true;
+
+					if user.features.hasOwnProperty('dropbox')
+						| Countly.q.push([ 'userData.set', 'features-dropbox', #{ user.features.dropbox } ]);
+						- featureFlagSet = true;
+
+					if user.features.hasOwnProperty('github')
+						| Countly.q.push([ 'userData.set', 'features-github', #{ user.features.github } ]);
+						- featureFlagSet = true;
+
+					if user.features.hasOwnProperty('references')
+						| Countly.q.push([ 'userData.set', 'features-references', #{ user.features.references } ]);
+						- featureFlagSet = true;
+
+					if user.features.hasOwnProperty('templates')
+						| Countly.q.push([ 'userData.set', 'features-templates', #{ user.features.templates } ]);
+						- featureFlagSet = true;
+
+					if user.features.hasOwnProperty('versioning')
+						| Countly.q.push([ 'userData.set', 'features-versioning', #{ user.features.versioning } ]);
+						- featureFlagSet = true;
+
+
+					if featureFlagSet
+						| Countly.q.push(['userData.save'])
+
+		// End countly Analytics
+				
 		script(type="text/javascript").
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";
 			window.csrfToken = "#{csrfToken}";
 			
 			
 		block scripts
 		block scripts
-		script(src="#{jsPath}libs/jquery-1.11.1.min.js")
-		script(src="#{jsPath}libs/angular-1.3.15.min.js")
-		include sentry
+		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
+		script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
 		script.
 		script.
 			window.sharelatex = {
 			window.sharelatex = {
 				siteUrl: '#{settings.siteUrl}',
 				siteUrl: '#{settings.siteUrl}',
@@ -123,12 +191,14 @@ html(itemscope, itemtype='http://schema.org/Product')
 					}
 					}
 				};
 				};
 			script(
 			script(
-				data-main=jsPath+'main.js',
-				baseurl=jsPath,
-				src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
+				data-main=buildJsPath('main.js', {fingerprint:false}),
+				baseurl=fullJsPath,
+				src=buildJsPath('libs/require.js')
 			)
 			)
 
 
-		include contact-us-modal
 
 
+		include contact-us-modal
+		include sentry
+		
 
 
 
 

+ 3 - 4
services/web/app/views/layout/footer.jade

@@ -15,7 +15,7 @@ footer.site-footer
 							aria-expanded="false",
 							aria-expanded="false",
 							tooltip="#{translate('language')}"
 							tooltip="#{translate('language')}"
 						)
 						)
-							img(src="/img/flags/24/#{currentLngCode}.png")
+							figure(class="sprite-icon sprite-icon-lang sprite-icon-#{currentLngCode}")
 
 
 						ul.dropdown-menu(role="menu")
 						ul.dropdown-menu(role="menu")
 							li.dropdown-header #{translate("language")}
 							li.dropdown-header #{translate("language")}
@@ -23,9 +23,9 @@ footer.site-footer
 								if !subdomainDetails.hide
 								if !subdomainDetails.hide
 									li.lngOption
 									li.lngOption
 										a.menu-indent(href=subdomainDetails.url+currentUrl)
 										a.menu-indent(href=subdomainDetails.url+currentUrl)
-											img(src="/img/flags/24/#{subdomainDetails.lngCode}.png")
+											figure(class="sprite-icon sprite-icon-lang sprite-icon-#{subdomainDetails.lngCode}")
 											|  #{translate(subdomainDetails.lngCode)}
 											|  #{translate(subdomainDetails.lngCode)}
-										
+										//- img(src="/img/flags/24/.png")
 				each item in nav.left_footer
 				each item in nav.left_footer
 					li
 					li
 						if item.url
 						if item.url
@@ -41,4 +41,3 @@ footer.site-footer
 							a(href=item.url, class=item.class) !{item.text}
 							a(href=item.url, class=item.class) !{item.text}
 						else
 						else
 							| !{item.text}
 							| !{item.text}
-

+ 5 - 1
services/web/app/views/layout/navbar.jade

@@ -3,7 +3,9 @@ nav.navbar.navbar-default
 		.navbar-header
 		.navbar-header
 			button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}")
 			button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}")
 				i.fa.fa-bars
 				i.fa.fa-bars
-			if nav.title
+			if settings.nav.custom_logo
+				a(href='/', style='background-image:url("#{settings.nav.custom_logo}")').navbar-brand
+			else if (nav.title)
 				a(href='/').navbar-title #{nav.title}
 				a(href='/').navbar-title #{nav.title}
 			else
 			else
 				a(href='/').navbar-brand
 				a(href='/').navbar-brand
@@ -17,6 +19,8 @@ nav.navbar.navbar-default
 							| Admin
 							| Admin
 							b.caret
 							b.caret
 						ul.dropdown-menu
 						ul.dropdown-menu
+							li
+								a(href="/admin") Manage Site
 							li
 							li
 								a(href="/admin/user") Manage Users
 								a(href="/admin/user") Manage Users
 
 

+ 10 - 6
services/web/app/views/project/editor.jade

@@ -97,9 +97,10 @@ block content
 		window.csrfToken = "!{csrfToken}";
 		window.csrfToken = "!{csrfToken}";
 		window.anonymous = #{anonymous};
 		window.anonymous = #{anonymous};
 		window.maxDocLength = #{maxDocLength};
 		window.maxDocLength = #{maxDocLength};
+		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 		window.requirejs = {
 			"paths" : {
 			"paths" : {
-				"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML",
+				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {qs:{config:'TeX-AMS_HTML', fingerprint:false}})}",
 				"moment": "libs/moment-2.7.0",
 				"moment": "libs/moment-2.7.0",
 				"libs/pdf": "libs/pdfjs-1.3.91/pdf"
 				"libs/pdf": "libs/pdfjs-1.3.91/pdf"
 			},
 			},
@@ -128,15 +129,18 @@ block content
 
 
 	- var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js'
 	- var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js'
 	- var fingerprintedPath = fingerprint(jsPath+pdfPath)
 	- var fingerprintedPath = fingerprint(jsPath+pdfPath)
-	- var pdfJsWorkerPath = jsPath+pdfPath+'?fingerprint='+fingerprintedPath
+	- var pdfJsWorkerPath = buildJsPath(pdfPath, {cdn:false,qs:{fingerprint:fingerprintedPath}}) // don't use worker for cdn
+
+
 	script(type='text/javascript').
 	script(type='text/javascript').
 		window.pdfJsWorkerPath = "#{pdfJsWorkerPath}";
 		window.pdfJsWorkerPath = "#{pdfJsWorkerPath}";
 
 
 	script(
 	script(
-		data-main=jsPath+"ide.js",
-		baseurl=jsPath,
-		data-ace-base=jsPath+'ace',
-		src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
+		data-main=buildJsPath("ide.js", {fingerprint:false}),
+		baseurl=fullJsPath,
+		data-ace-base=buildJsPath('ace', {fingerprint:false}),
+		src=buildJsPath('libs/require.js')
 	)
 	)
+
 		
 		
 
 

+ 1 - 1
services/web/app/views/project/editor/editor.jade

@@ -35,7 +35,7 @@ div.full-size(
 			resize-on="layout:main:resize,layout:pdf:resize",
 			resize-on="layout:main:resize,layout:pdf:resize",
 			annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
 			annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
 			read-only="!permissions.write",
 			read-only="!permissions.write",
-			on-ctrl-enter="recompile"
+			on-ctrl-enter="recompileViaKey"
 		)
 		)
 
 
 	.ui-layout-east
 	.ui-layout-east

+ 1 - 1
services/web/app/views/project/editor/left-menu.jade

@@ -15,7 +15,7 @@ aside#left-menu.full-size(
 				| #{translate("source")}
 				| #{translate("source")}
 		li
 		li
 			a(
 			a(
-				ng-href="{{pdf.url}}"
+				ng-href="{{pdf.downloadUrl || pdf.url}}"
 				target="_blank"
 				target="_blank"
 				ng-if="pdf.url"
 				ng-if="pdf.url"
 			)
 			)

+ 133 - 7
services/web/app/views/project/editor/pdf.jade

@@ -1,6 +1,13 @@
 div.full-size.pdf(ng-controller="PdfController")
 div.full-size.pdf(ng-controller="PdfController")
 	.toolbar.toolbar-tall
 	.toolbar.toolbar-tall
-		.btn-group(dropdown)
+		.btn-group(
+			dropdown,
+			tooltip-html="'#{translate('recompile_pdf')} <span class=\"keyboard-shortcut\">({{modifierKey}} + Enter)</span>'"
+			tooltip-class="keyboard-tooltip"
+			tooltip-popup-delay="500"
+			tooltip-append-to-body="true"
+			tooltip-placement="bottom"
+		)
 			a.btn.btn-info(
 			a.btn.btn-info(
 				href,
 				href,
 				ng-disabled="pdf.compiling",
 				ng-disabled="pdf.compiling",
@@ -29,6 +36,14 @@ div.full-size.pdf(ng-controller="PdfController")
 						i.fa.fa-fw(ng-class="{'fa-check': draft}")
 						i.fa.fa-fw(ng-class="{'fa-check': draft}")
 						| &nbsp;#{translate("fast")}&nbsp;
 						| &nbsp;#{translate("fast")}&nbsp;
 						span.subdued [draft]
 						span.subdued [draft]
+		a(
+			href
+			ng-click="stop()"
+			ng-show="pdf.compiling",
+			tooltip="#{translate('stop_compile')}"
+			tooltip-placement="bottom"
+		)
+			i.fa.fa-stop()
 		a.log-btn(
 		a.log-btn(
 			href
 			href
 			ng-click="toggleLogs()"
 			ng-click="toggleLogs()"
@@ -46,7 +61,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			) {{ pdf.logEntries.errors.length + pdf.logEntries.warnings.length }}
 			) {{ pdf.logEntries.errors.length + pdf.logEntries.warnings.length }}
 			
 			
 		a(
 		a(
-			ng-href="{{pdf.url}}"
+			ng-href="{{pdf.downloadUrl || pdf.url}}"
 			target="_blank"
 			target="_blank"
 			ng-if="pdf.url"
 			ng-if="pdf.url"
 			tooltip="#{translate('download_pdf')}"
 			tooltip="#{translate('download_pdf')}"
@@ -93,15 +108,98 @@ div.full-size.pdf(ng-controller="PdfController")
 					'alert-info':    entry.level == 'typesetting'\
 					'alert-info':    entry.level == 'typesetting'\
 				}"
 				}"
 				ng-click="openInEditor(entry)"
 				ng-click="openInEditor(entry)"
+				ng-init="feedbackSent = false; showNegFeedbackUI = false; negFeedbackReason = ''; negFeedbackReasonFreeText = ''"
 			)
 			)
 				span.line-no
 				span.line-no
+					i.fa.fa-link(aria-hidden="true")
+					| &nbsp;
 					span(ng-show="entry.file") {{ entry.file }}
 					span(ng-show="entry.file") {{ entry.file }}
 					span(ng-show="entry.line") , line {{ entry.line }}
 					span(ng-show="entry.line") , line {{ entry.line }}
 				p.entry-message(ng-show="entry.message") {{ entry.message }}
 				p.entry-message(ng-show="entry.message") {{ entry.message }}
-				p.entry-content(ng-show="entry.content") {{ entry.content }}
+				.card.card-hint(
+					ng-if="entry.humanReadableHint"
+					stop-propagation="click"
+				)
+					figure.card-hint-icon-container
+						i.fa.fa-lightbulb-o(aria-hidden="true")
+					p.card-hint-text(
+						ng-show="entry.humanReadableHint",
+						ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
+					.card-hint-actions.clearfix
+						.card-hint-ext-link(ng-if="wikiEnabled")
+							a(
+								ng-href="{{ entry.extraInfoURL }}",
+								ng-click="trackLogHintsLearnMore()"
+								target="_blank"
+							)
+								i.fa.fa-external-link
+								|&nbsp;#{translate("log_hint_extra_info")}
+						.card-hint-feedback(
+							ng-hide="feedbackSent || showNegFeedbackUI"
+							ng-class="entry.ruleId"
+						)
+							label.card-hint-feedback-label #{translate("log_hint_feedback_label")}
+							a.card-hint-feedback-positive(
+								ng-click="trackLogHintsPositiveFeedback(entry.ruleId); feedbackSent = true;"
+								href
+							) #{translate("answer_yes")}
+							span &nbsp;/&nbsp;
+							a.card-hint-feedback-negative(
+								ng-click="trackLogHintsNegativeFeedback(entry.ruleId); showNegFeedbackUI = true;"
+								href
+							) #{translate("answer_no")}
+						.card-hint-extra-feedback(ng-hide="!showNegFeedbackUI || feedbackSent")
+							p.card-hint-extra-feedback-label #{translate("log_hint_ask_extra_feedback")}
+							.radio: label
+								input(
+									type="radio"
+									name="{{ 'neg-feedback-reason-' + $index }}"
+									ng-model="negFeedbackReason"
+									value="{{ logHintsNegFeedbackValues.DIDNT_UNDERSTAND }}"
+								)
+								| #{translate("log_hint_extra_feedback_didnt_understand")}
+							.radio: label
+								input(
+									type="radio"
+									name="{{ 'neg-feedback-reason-' + $index }}"
+									ng-model="negFeedbackReason"
+									value="{{ logHintsNegFeedbackValues.NOT_APPLICABLE }}"
+								)
+								| #{translate("log_hint_extra_feedback_not_applicable")}
+							.radio: label
+								input(
+									type="radio"
+									name="{{ 'neg-feedback-reason-' + $index }}"
+									ng-model="negFeedbackReason"
+									value="{{ logHintsNegFeedbackValues.INCORRECT }}"
+								)
+								| #{translate("log_hint_extra_feedback_incorrect")}
+							.radio: label
+								input(
+									type="radio"
+									name="{{ 'neg-feedback-reason-' + $index }}"
+									ng-model="negFeedbackReason"
+									value="{{ logHintsNegFeedbackValues.OTHER }}"
+								)
+								| #{translate("log_hint_extra_feedback_other")}
+							textarea.form-control(
+								ng-show="negFeedbackReason === logHintsNegFeedbackValues.OTHER"
+								ng-model="negFeedbackReasonFreeText"
+								rows="2"
+							)
+							.clearfix
+								button.btn.btn-default.btn-sm.pull-right(
+									ng-disabled="!negFeedbackReason"
+									ng-click="trackLogHintsNegFeedbackDetails(entry.ruleId, negFeedbackReason, negFeedbackReasonFreeText); feedbackSent = true;"
+								) #{translate("log_hint_extra_feedback_submit")}
+
+						.card-hint-feedback(ng-show="feedbackSent")
+							label.card-hint-feedback-label #{translate("log_hint_feedback_gratitude")}
+
+				p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
 
 
 		p
 		p
-			.pull-right
+			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 				a.btn.btn-default.btn-sm(
 					href,
 					href,
 					tooltip="#{translate('clear_cached_files')}",
 					tooltip="#{translate('clear_cached_files')}",
@@ -111,12 +209,15 @@ div.full-size.pdf(ng-controller="PdfController")
 				)
 				)
 					i.fa.fa-trash-o
 					i.fa.fa-trash-o
 				| &nbsp;
 				| &nbsp;
-				div.dropdown(style="display: inline-block;", dropdown)
+				div.files-dropdown(
+					ng-class="shouldDropUp ? 'dropup' : 'dropdown'"
+					dropdown
+				)
 					a.btn.btn-default.btn-sm(
 					a.btn.btn-default.btn-sm(
 						href
 						href
 						dropdown-toggle
 						dropdown-toggle
 					)
 					)
-						| !{translate("other_logs_and_files")} 
+						| !{translate("other_logs_and_files")}
 						span.caret
 						span.caret
 					ul.dropdown-menu.dropdown-menu-right
 					ul.dropdown-menu.dropdown-menu-right
 						li(ng-repeat="file in pdf.outputFiles")
 						li(ng-repeat="file in pdf.outputFiles")
@@ -154,6 +255,27 @@ div.full-size.pdf(ng-controller="PdfController")
 				ng-if="settings.pdfViewer == 'native'"
 				ng-if="settings.pdfViewer == 'native'"
 			)
 			)
 
 
+		.pdf-validation-problems(ng-switch-when="validation-problems")
+
+			.alert.alert-danger(ng-show="pdf.validation.duplicatePaths")
+				strong #{translate("latex_error")}
+				span #{translate("duplicate_paths_found")}
+
+
+			.alert.alert-danger(ng-show="pdf.validation.sizeCheck")
+				strong #{translate("project_too_large")} 
+				div #{translate("project_too_large_please_reduce")}
+				div
+					li(ng-repeat="entry in pdf.validation.sizeCheck.resources") {{ '/'+entry['path'] }} - {{entry['kbSize']}}kb
+	
+			.alert.alert-danger(ng-show="pdf.validation.conflictedPaths")
+				div
+					strong #{translate("conflicting_paths_found")}
+				div #{translate("following_paths_conflict")}
+				div
+					li(ng-repeat="entry in pdf.validation.conflictedPaths") {{ '/'+entry['path'] }}
+
+
 		.pdf-errors(ng-switch-when="errors")
 		.pdf-errors(ng-switch-when="errors")
 	
 	
 			.alert.alert-danger(ng-show="pdf.error")
 			.alert.alert-danger(ng-show="pdf.error")
@@ -171,7 +293,11 @@ div.full-size.pdf(ng-controller="PdfController")
 			.alert.alert-danger(ng-show="pdf.tooRecentlyCompiled")
 			.alert.alert-danger(ng-show="pdf.tooRecentlyCompiled")
 				strong #{translate("server_error")} 
 				strong #{translate("server_error")} 
 				span #{translate("too_recently_compiled")}
 				span #{translate("too_recently_compiled")}
-	
+
+			.alert.alert-danger(ng-show="pdf.compileTerminated")
+				strong #{translate("terminated")}. 
+				span #{translate("compile_terminated_by_user")}
+
 			.alert.alert-danger(ng-show="pdf.timedout")
 			.alert.alert-danger(ng-show="pdf.timedout")
 				p
 				p
 					strong #{translate("timedout")}. 
 					strong #{translate("timedout")}. 

+ 1 - 1
services/web/app/views/project/editor/share.jade

@@ -49,7 +49,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					.form-group
 					.form-group
 						tags-input(
 						tags-input(
 							template="shareTagTemplate"
 							template="shareTagTemplate"
-							placeholder="joe@example.com, sue@example.com, ..."
+							placeholder="#{settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...'}"
 							ng-model="inputs.contacts"
 							ng-model="inputs.contacts"
 							focus-on="open"
 							focus-on="open"
 							display-property="display"
 							display-property="display"

+ 5 - 16
services/web/app/views/project/list/side-bar.jade

@@ -94,18 +94,7 @@
 - if (showUserDetailsArea)
 - if (showUserDetailsArea)
 	span(ng-controller="LeftHandMenuPromoController",  ng-cloak)
 	span(ng-controller="LeftHandMenuPromoController",  ng-cloak)
 
 
-		.row-spaced(ng-if="showDatajoy")
-			hr
-			.card.card-thin
-				p.text-center.small
-					| <strong>Python</strong> or <strong>R</strong> user?
-					p.text-center.small
-						a(href="https://www.getdatajoy.com/", target="_blank").btn.btn-info.btn-small Try DataJoy
-				p.text-center.small(style="font-size: 0.8em")
-					a(href="https://www.getdatajoy.com/", target="_blank") DataJoy
-					|  is a new online Python and R editor from ShareLaTeX.
-
-		.row-spaced#userProfileInformation(ng-if="hasProjects && !showDatajoy")
+		.row-spaced#userProfileInformation(ng-if="hasProjects")
 			div(ng-controller="UserProfileController")
 			div(ng-controller="UserProfileController")
 				hr(ng-show="percentComplete < 100")
 				hr(ng-show="percentComplete < 100")
 				.text-centered.user-profile(ng-show="percentComplete < 100")
 				.text-centered.user-profile(ng-show="percentComplete < 100")
@@ -120,7 +109,7 @@
 					) #{translate("complete")}
 					) #{translate("complete")}
 
 
 
 
-		.row-spaced(ng-if="hasProjects && userHasSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
+		.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
 			span(sixpack-default).text-centered
 			span(sixpack-default).text-centered
 				hr
 				hr
 				p.small #{translate("on_free_sl")}
 				p.small #{translate("on_free_sl")}
@@ -146,7 +135,7 @@
 						p
 						p
 							span Get Dropbox Sync
 							span Get Dropbox Sync
 						p
 						p
-							img(src="/img/dropbox/simple_logo.png")
+							img(src=buildImgPath("dropbox/simple_logo.png"))
 						p
 						p
 							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
 							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
 						p.small.text-centered
 						p.small.text-centered
@@ -159,13 +148,13 @@
 						p
 						p
 							span Get Github Sync
 							span Get Github Sync
 						p
 						p
-							img(src="/img/github/octocat.jpg")
+							img(src=buildImgPath("github/octocat.jpg"))
 						p
 						p
 							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
 							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
 						p.small.text-centered
 						p.small.text-centered
 							| #{translate("or_unlock_features_bonus")} 
 							| #{translate("or_unlock_features_bonus")} 
 							a(href="/user/bonus") #{translate("sharing_sl")} .
 							a(href="/user/bonus") #{translate("sharing_sl")} .
 script.
 script.
-	window.userHasSubscription = #{settings.enableSubscriptions && !hasSubscription}
+	window.userHasNoSubscription = #{settings.enableSubscriptions && !hasSubscription}
 
 
 
 

+ 1 - 1
services/web/app/views/sentry.jade

@@ -2,7 +2,7 @@
 	- if (sentrySrc.match(/^([a-z]+:)?\/\//i))
 	- if (sentrySrc.match(/^([a-z]+:)?\/\//i))
 		script(src="#{sentrySrc}")
 		script(src="#{sentrySrc}")
 	- else
 	- else
-		script(src="#{jsPath}libs/#{sentrySrc}")
+		script(src=buildJsPath("libs/#{sentrySrc}", {fingerprint:false}))
 - if (typeof(sentrySrc) != "undefined")
 - if (typeof(sentrySrc) != "undefined")
 	script(type="text/javascript").
 	script(type="text/javascript").
 		if (typeof(Raven) != "undefined" && Raven.config) {
 		if (typeof(Raven) != "undefined" && Raven.config) {

+ 17 - 13
services/web/app/views/subscriptions/dashboard.jade

@@ -43,12 +43,8 @@ block content
 					.card(ng-if="view == 'overview'")
 					.card(ng-if="view == 'overview'")
 						.page-header
 						.page-header
 							h1 #{translate("your_subscription")}
 							h1 #{translate("your_subscription")}
-						-if (groups.length != 0)
-							each groupSubscription in groups
-								p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
-								span 
-									button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
-						- else if (subscription)
+
+						- if (subscription && user._id+'' == subscription.admin_id+'')
 							case subscription.state
 							case subscription.state
 								when "free-trial"
 								when "free-trial"
 									p !{translate("on_free_trial_expiring_at", {expiresAt:"<strong>" + subscription.expiresAt + "</strong>"})}
 									p !{translate("on_free_trial_expiring_at", {expiresAt:"<strong>" + subscription.expiresAt + "</strong>"})}
@@ -72,15 +68,9 @@ block content
 									p !{translate("your_subscription_has_expired")}	
 									p !{translate("your_subscription_has_expired")}	
 										a(href="/user/subscription/plans") !{translate("create_new_subscription")}
 										a(href="/user/subscription/plans") !{translate("create_new_subscription")}
 								default
 								default
-									-if(groups.length == 0)
+									-if(groupSubscriptions.length == 0)
 										p !{translate("problem_with_subscription_contact_us")}
 										p !{translate("problem_with_subscription_contact_us")}
 
 
-							-if(subscription.groupPlan)
-								a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
-
-								
-
-
 							div(ng-show="changePlan", ng-cloak)#changePlanSection
 							div(ng-show="changePlan", ng-cloak)#changePlanSection
 								h2.col-md-7 !{translate("change_plan")}
 								h2.col-md-7 !{translate("change_plan")}
 								span.dropdown.col-md-1.changePlanButton(ng-controller="CurrenyDropdownController", style="display:none", dropdown)
 								span.dropdown.col-md-1.changePlanButton(ng-controller="CurrenyDropdownController", style="display:none", dropdown)
@@ -105,6 +95,20 @@ block content
 									mixin printPlans(plans.individualMonthlyPlans)
 									mixin printPlans(plans.individualMonthlyPlans)
 									mixin printPlans(plans.individualAnnualPlans)
 									mixin printPlans(plans.individualAnnualPlans)
 							
 							
+
+						each groupSubscription in groupSubscriptions
+							- if (user._id+'' != groupSubscription.admin_id._id+'')
+								div 
+									p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
+									span 
+										button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
+
+						-if(subscription.groupPlan && user._id+'' == subscription.admin_id+'')
+							div
+								a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
+
+
+
 					.card(ng-if="view == 'cancelation'")
 					.card(ng-if="view == 'cancelation'")
 						.page-header
 						.page-header
 							h1 #{translate("Cancel Subscription")}
 							h1 #{translate("Cancel Subscription")}

+ 1 - 1
services/web/app/views/subscriptions/edit-billing-details.jade

@@ -3,7 +3,7 @@ extends ../layout
 block content
 block content
 	- locals.supressDefaultJs = true
 	- locals.supressDefaultJs = true
 	script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
 	script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
-	script(src=jsPath+'libs/recurly.min.js')
+	script(src=buildJsPath('libs/recurly.min.js'))
 
 
 	.content.content-alt
 	.content.content-alt
 		.container
 		.container

+ 2 - 2
services/web/app/views/subscriptions/successful_subscription.jade

@@ -30,10 +30,10 @@ block content
 								| Henry and James
 								| Henry and James
 							.portraits
 							.portraits
 								span.img-circle
 								span.img-circle
-									img(src="/img/about/henry_oswald.jpg") 
+									img(src=buildImgPath("about/henry_oswald.jpg"))
 								| &nbsp;
 								| &nbsp;
 								span.img-circle
 								span.img-circle
-									img(src="/img/about/james_allen.jpg") 
+									img(src=buildImgPath("about/james_allen.jpg"))
 						p
 						p
 							a.btn.btn-primary(href="/project") &lt; #{translate("back_to_your_projects")}
 							a.btn.btn-primary(href="/project") &lt; #{translate("back_to_your_projects")}
 
 

+ 1 - 1
services/web/app/views/translations/translation_message.jade

@@ -2,7 +2,7 @@
 	span(ng-controller="TranslationsPopupController", ng-cloak)
 	span(ng-controller="TranslationsPopupController", ng-cloak)
 		.translations-message(ng-hide="hidei18nNotification")
 		.translations-message(ng-hide="hidei18nNotification")
 			a(href=recomendSubdomain.url+currentUrl) !{translate("click_here_to_view_sl_in_lng", {lngName:"<strong>" + translate(recomendSubdomain.lngCode) + "</strong>"})} 
 			a(href=recomendSubdomain.url+currentUrl) !{translate("click_here_to_view_sl_in_lng", {lngName:"<strong>" + translate(recomendSubdomain.lngCode) + "</strong>"})} 
-				img(src="/img/flags/24/#{recomendSubdomain.lngCode}.png")
+				img(src=buildImgPath("flags/24/#{recomendSubdomain.lngCode}.png"))
 			button(ng-click="dismiss()").close.pull-right
 			button(ng-click="dismiss()").close.pull-right
 				span(aria-hidden="true") &times;
 				span(aria-hidden="true") &times;
 				span.sr-only #{translate("close")}
 				span.sr-only #{translate("close")}

+ 19 - 0
services/web/app/views/university/case_study.jade

@@ -0,0 +1,19 @@
+extends ../layout
+
+block content
+	.masthead
+		.container
+			.row
+				.col-md-12
+					h1  !{viewData.title}
+			.row.row-spaced
+
+	.pattern-container
+		.content
+			.container
+				.row
+					.col-md-10.col-md-offset-1
+						.card
+							.row
+								| !{viewData.html}
+

+ 11 - 0
services/web/app/views/user/settings.jade

@@ -99,6 +99,17 @@ block content
 
 
 							| !{moduleIncludes("userSettings", locals)}
 							| !{moduleIncludes("userSettings", locals)}
 
 
+							if (user.betaProgram)
+								hr
+
+								h3
+									| #{translate("sharelatex_beta_program")}
+
+								p.small
+									| #{translate("beta_program_already_participating")}
+								div
+									a(id="beta-program-participate-link" href="/beta/participate") #{translate("manage_beta_program_membership")}
+
 							hr
 							hr
 
 
 							if !externalAuthenticationSystemUsed()
 							if !externalAuthenticationSystemUsed()

+ 0 - 68
services/web/app/views/wiki/page.jade

@@ -1,68 +0,0 @@
-extends ../layout
-
-block content
-	.content.content-alt(ng-cloak)
-		.container.wiki
-			.row.template-page-header
-				.col-md-8(ng-cloak)
-
-			.row
-				.col-xs-3.contents(ng-non-bindable)
-					| !{contents.content}
-
-				.col-xs-9.page
-					- if(typeof(settings.algolia) != "undefined" && typeof(settings.algolia.indexes) != "undefined" && typeof(settings.algolia.indexes.wiki) != "undefined")
-						span(ng-controller="SearchWikiController")
-							.row
-								form.project-search.form-horizontal.col-md-9(role="form")
-									.form-group.has-feedback.has-feedback-left.col-md-12
-										input.form-control.col-md-12(type='text', ng-model='searchQueryText', ng-keyup='search()', placeholder="Search help library....")
-										i.fa.fa-search.form-control-feedback-left
-										i.fa.fa-times.form-control-feedback(
-											ng-click="clearSearchText()",
-											style="cursor: pointer;",
-											ng-show="searchQueryText.length > 0"
-										)
-								.col-md-3.text-right
-									a.btn.btn-primary(ng-click="showMissingTemplateModal()") #{translate("suggest_new_doc")}
-
-							.row
-								.col-md-12(ng-cloak)
-									a(ng-href='{{hit.url}}',ng-repeat='hit in hits').search-result.card.card-thin
-										span(ng-bind-html='hit.name')
-										div.search-result-content(ng-show="hit.content != ''", ng-bind-html='hit.content')
-
-					.card.row-spaced(ng-non-bindable)
-						.page-header
-							h1 #{title}
-							
-						| !{page.content}
-
-
-
-
-	script(type="text/ng-template", id="missingWikiPageModal")
-		.modal-header
-			button.close(
-				type="button"
-				data-dismiss="modal"
-				ng-click="close()"
-			) &times;
-			h3 #{translate("suggest_new_doc")}
-		.modal-body.contact-us-modal
-			span(ng-show="sent == false")
-				label.desc
-					| #{translate("email")} (#{translate("optional")})
-				.form-group
-					input.field.text.medium.span8.form-control(ng-model="form.email",  ng-init="form.email = '#{getUserEmail()}'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
-				label.desc
-					| #{translate("suggestion")}
-				.form-group
-					textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', maxlength='255', tabindex='4', onkeyup='')
-			span(ng-show="sent")
-				p #{translate("request_sent_thank_you")}
-		.modal-footer
-			button.btn.btn-default(ng-click="close()") 
-				span #{translate("dismiss")}
-			button.btn-success.btn(type='submit', ng-disabled="sending", ng-click="contactUs()") #{translate("contact_us")}
-

+ 18 - 6
services/web/config/settings.defaults.coffee

@@ -15,7 +15,7 @@ httpAuthUsers[httpAuthUser] = httpAuthPass
 
 
 sessionSecret = "secret-please-change"
 sessionSecret = "secret-please-change"
 
 
-module.exports =
+module.exports = settings =
 	# File storage
 	# File storage
 	# ------------
 	# ------------
 	#
 	#
@@ -104,16 +104,21 @@ module.exports =
 			url: "http://localhost:3036"
 			url: "http://localhost:3036"
 		sixpack:
 		sixpack:
 			url: ""
 			url: ""
-		references:
-			url: "http://localhost:3040"
-		notifications:
-			url: "http://localhost:3042" 
+		# references:
+		# 	url: "http://localhost:3040"
+		# notifications:
+		# 	url: "http://localhost:3042" 
 			
 			
 	templates:
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
 		showSocialButtons: false
 		showSocialButtons: false
 		showComments: false
 		showComments: false
 
 
+	# cdn:
+	# 	web:
+	# 		host:"http://cdn.sharelatex.dev:3000"
+	#		darkHost:"http://cdn.sharelatex.dev:3000"
+
 	# Where your instance of ShareLaTeX can be found publically. Used in emails
 	# Where your instance of ShareLaTeX can be found publically. Used in emails
 	# that are sent out, generated links, etc.
 	# that are sent out, generated links, etc.
 	siteUrl : siteUrl = 'http://localhost:3000'
 	siteUrl : siteUrl = 'http://localhost:3000'
@@ -137,6 +142,7 @@ module.exports =
 	# --------
 	# --------
 	security:
 	security:
 		sessionSecret: sessionSecret
 		sessionSecret: sessionSecret
+		bcryptRounds: 12 # number of rounds used to hash user passwords (raised to power 2)
 
 
 	httpAuthUsers: httpAuthUsers
 	httpAuthUsers: httpAuthUsers
 
 
@@ -261,6 +267,10 @@ module.exports =
 	# Should we allow access to any page without logging in? This includes
 	# Should we allow access to any page without logging in? This includes
 	# public projects, /learn, /templates, about pages, etc.
 	# public projects, /learn, /templates, about pages, etc.
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
+
+	# Use a single compile directory for all users in a project
+	# (otherwise each user has their own directory)
+	# disablePerUserCompiles: true
 	
 	
 	# Maximum size of text documents in the real-time editing system.
 	# Maximum size of text documents in the real-time editing system.
 	max_doc_length: 2 * 1024 * 1024 # 2mb
 	max_doc_length: 2 * 1024 * 1024 # 2mb
@@ -334,7 +344,9 @@ module.exports =
 				url: "/logout"
 				url: "/logout"
 			}]
 			}]
 		}]
 		}]
-	
+
+	customisation: {}
+
 #	templates: [{
 #	templates: [{
 #		name : "cv_or_resume",
 #		name : "cv_or_resume",
 #		url : "/templates/cv"
 #		url : "/templates/cv"

+ 3853 - 0
services/web/npm-shrinkwrap.json

@@ -0,0 +1,3853 @@
+{
+  "name": "web-sharelatex",
+  "version": "0.1.4",
+  "dependencies": {
+    "archiver": {
+      "version": "0.9.0",
+      "from": "archiver@0.9.0",
+      "resolved": "https://registry.npmjs.org/archiver/-/archiver-0.9.0.tgz",
+      "dependencies": {
+        "buffer-crc32": {
+          "version": "0.2.5",
+          "from": "buffer-crc32@~0.2.1",
+          "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.5.tgz"
+        },
+        "readable-stream": {
+          "version": "1.0.34",
+          "from": "readable-stream@~1.0.24",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+          "dependencies": {
+            "core-util-is": {
+              "version": "1.0.2",
+              "from": "core-util-is@~1.0.0",
+              "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+            },
+            "isarray": {
+              "version": "0.0.1",
+              "from": "isarray@0.0.1",
+              "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+            },
+            "string_decoder": {
+              "version": "0.10.31",
+              "from": "string_decoder@~0.10.x",
+              "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+            },
+            "inherits": {
+              "version": "2.0.1",
+              "from": "inherits@~2.0.1",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+            }
+          }
+        },
+        "tar-stream": {
+          "version": "0.3.3",
+          "from": "tar-stream@~0.3.0",
+          "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-0.3.3.tgz",
+          "dependencies": {
+            "bl": {
+              "version": "0.6.0",
+              "from": "bl@~0.6.0",
+              "resolved": "https://registry.npmjs.org/bl/-/bl-0.6.0.tgz"
+            },
+            "end-of-stream": {
+              "version": "0.1.5",
+              "from": "end-of-stream@~0.1.3",
+              "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz",
+              "dependencies": {
+                "once": {
+                  "version": "1.3.3",
+                  "from": "once@~1.3.0",
+                  "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+                  "dependencies": {
+                    "wrappy": {
+                      "version": "1.0.2",
+                      "from": "wrappy@1",
+                      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        },
+        "zip-stream": {
+          "version": "0.3.7",
+          "from": "zip-stream@~0.3.0",
+          "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-0.3.7.tgz",
+          "dependencies": {
+            "crc32-stream": {
+              "version": "0.2.0",
+              "from": "crc32-stream@~0.2.0",
+              "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-0.2.0.tgz"
+            },
+            "debug": {
+              "version": "1.0.4",
+              "from": "debug@~1.0.2",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
+              "dependencies": {
+                "ms": {
+                  "version": "0.6.2",
+                  "from": "ms@0.6.2",
+                  "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz"
+                }
+              }
+            },
+            "deflate-crc32-stream": {
+              "version": "0.1.2",
+              "from": "deflate-crc32-stream@~0.1.0",
+              "resolved": "https://registry.npmjs.org/deflate-crc32-stream/-/deflate-crc32-stream-0.1.2.tgz"
+            }
+          }
+        },
+        "lazystream": {
+          "version": "0.1.0",
+          "from": "lazystream@~0.1.0",
+          "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-0.1.0.tgz"
+        },
+        "file-utils": {
+          "version": "0.1.5",
+          "from": "file-utils@~0.1.5",
+          "resolved": "https://registry.npmjs.org/file-utils/-/file-utils-0.1.5.tgz",
+          "dependencies": {
+            "lodash": {
+              "version": "2.1.0",
+              "from": "lodash@~2.1.0",
+              "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.1.0.tgz"
+            },
+            "iconv-lite": {
+              "version": "0.2.11",
+              "from": "iconv-lite@~0.2.11",
+              "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.2.11.tgz"
+            },
+            "glob": {
+              "version": "3.2.11",
+              "from": "glob@~3.2.6",
+              "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz",
+              "dependencies": {
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@2",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                },
+                "minimatch": {
+                  "version": "0.3.0",
+                  "from": "minimatch@0.3",
+                  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz",
+                  "dependencies": {
+                    "lru-cache": {
+                      "version": "2.7.3",
+                      "from": "lru-cache@2",
+                      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+                    },
+                    "sigmund": {
+                      "version": "1.0.1",
+                      "from": "sigmund@~1.0.0",
+                      "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+                    }
+                  }
+                }
+              }
+            },
+            "minimatch": {
+              "version": "0.2.14",
+              "from": "minimatch@~0.2.12",
+              "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+              "dependencies": {
+                "lru-cache": {
+                  "version": "2.7.3",
+                  "from": "lru-cache@2",
+                  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+                },
+                "sigmund": {
+                  "version": "1.0.1",
+                  "from": "sigmund@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+                }
+              }
+            },
+            "findup-sync": {
+              "version": "0.1.3",
+              "from": "findup-sync@~0.1.2",
+              "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.1.3.tgz",
+              "dependencies": {
+                "lodash": {
+                  "version": "2.4.2",
+                  "from": "lodash@~2.4.1",
+                  "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+                }
+              }
+            },
+            "isbinaryfile": {
+              "version": "0.1.9",
+              "from": "isbinaryfile@~0.1.9",
+              "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-0.1.9.tgz"
+            }
+          }
+        },
+        "lodash": {
+          "version": "2.4.2",
+          "from": "lodash@~2.4.1",
+          "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+        }
+      }
+    },
+    "async": {
+      "version": "0.6.2",
+      "from": "async@0.6.2",
+      "resolved": "https://registry.npmjs.org/async/-/async-0.6.2.tgz"
+    },
+    "base64-stream": {
+      "version": "0.1.3",
+      "from": "base64-stream@^0.1.2",
+      "resolved": "https://registry.npmjs.org/base64-stream/-/base64-stream-0.1.3.tgz",
+      "dependencies": {
+        "readable-stream": {
+          "version": "2.1.4",
+          "from": "readable-stream@^2.0.2",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.4.tgz",
+          "dependencies": {
+            "buffer-shims": {
+              "version": "1.0.0",
+              "from": "buffer-shims@^1.0.0",
+              "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
+            },
+            "core-util-is": {
+              "version": "1.0.2",
+              "from": "core-util-is@~1.0.0",
+              "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+            },
+            "inherits": {
+              "version": "2.0.1",
+              "from": "inherits@~2.0.1",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+            },
+            "isarray": {
+              "version": "1.0.0",
+              "from": "isarray@~1.0.0",
+              "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+            },
+            "process-nextick-args": {
+              "version": "1.0.7",
+              "from": "process-nextick-args@~1.0.6",
+              "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+            },
+            "string_decoder": {
+              "version": "0.10.31",
+              "from": "string_decoder@~0.10.x",
+              "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+            },
+            "util-deprecate": {
+              "version": "1.0.2",
+              "from": "util-deprecate@~1.0.1",
+              "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+            }
+          }
+        }
+      }
+    },
+    "basic-auth-connect": {
+      "version": "1.0.0",
+      "from": "basic-auth-connect@^1.0.0",
+      "resolved": "https://registry.npmjs.org/basic-auth-connect/-/basic-auth-connect-1.0.0.tgz"
+    },
+    "bcrypt": {
+      "version": "0.8.3",
+      "from": "bcrypt@0.8.3",
+      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-0.8.3.tgz",
+      "dependencies": {
+        "bindings": {
+          "version": "1.2.1",
+          "from": "bindings@1.2.1",
+          "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz"
+        },
+        "nan": {
+          "version": "1.8.4",
+          "from": "nan@1.8.4",
+          "resolved": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz"
+        }
+      }
+    },
+    "body-parser": {
+      "version": "1.15.1",
+      "from": "body-parser@^1.13.1",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.15.1.tgz",
+      "dependencies": {
+        "bytes": {
+          "version": "2.3.0",
+          "from": "bytes@2.3.0",
+          "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz"
+        },
+        "content-type": {
+          "version": "1.0.2",
+          "from": "content-type@~1.0.1",
+          "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz"
+        },
+        "debug": {
+          "version": "2.2.0",
+          "from": "debug@~2.2.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "dependencies": {
+            "ms": {
+              "version": "0.7.1",
+              "from": "ms@0.7.1",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+            }
+          }
+        },
+        "depd": {
+          "version": "1.1.0",
+          "from": "depd@~1.1.0",
+          "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz"
+        },
+        "http-errors": {
+          "version": "1.4.0",
+          "from": "http-errors@~1.4.0",
+          "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.4.0.tgz",
+          "dependencies": {
+            "inherits": {
+              "version": "2.0.1",
+              "from": "inherits@2.0.1",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+            },
+            "statuses": {
+              "version": "1.3.0",
+              "from": "statuses@>= 1.2.1 < 2",
+              "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz"
+            }
+          }
+        },
+        "iconv-lite": {
+          "version": "0.4.13",
+          "from": "iconv-lite@0.4.13",
+          "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz"
+        },
+        "on-finished": {
+          "version": "2.3.0",
+          "from": "on-finished@~2.3.0",
+          "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+          "dependencies": {
+            "ee-first": {
+              "version": "1.1.1",
+              "from": "ee-first@1.1.1",
+              "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
+            }
+          }
+        },
+        "qs": {
+          "version": "6.1.0",
+          "from": "qs@6.1.0",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.1.0.tgz"
+        },
+        "raw-body": {
+          "version": "2.1.6",
+          "from": "raw-body@~2.1.6",
+          "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.6.tgz",
+          "dependencies": {
+            "unpipe": {
+              "version": "1.0.0",
+              "from": "unpipe@1.0.0",
+              "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
+            }
+          }
+        },
+        "type-is": {
+          "version": "1.6.13",
+          "from": "type-is@~1.6.12",
+          "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.13.tgz",
+          "dependencies": {
+            "media-typer": {
+              "version": "0.3.0",
+              "from": "media-typer@0.3.0",
+              "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
+            },
+            "mime-types": {
+              "version": "2.1.11",
+              "from": "mime-types@~2.1.11",
+              "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+              "dependencies": {
+                "mime-db": {
+                  "version": "1.23.0",
+                  "from": "mime-db@~1.23.0",
+                  "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "bufferedstream": {
+      "version": "1.6.0",
+      "from": "bufferedstream@1.6.0",
+      "resolved": "https://registry.npmjs.org/bufferedstream/-/bufferedstream-1.6.0.tgz"
+    },
+    "connect-redis": {
+      "version": "2.3.0",
+      "from": "connect-redis@2.3.0",
+      "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-2.3.0.tgz",
+      "dependencies": {
+        "debug": {
+          "version": "1.0.4",
+          "from": "debug@^1.0.4",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
+          "dependencies": {
+            "ms": {
+              "version": "0.6.2",
+              "from": "ms@0.6.2",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz"
+            }
+          }
+        },
+        "redis": {
+          "version": "0.12.1",
+          "from": "redis@^0.12.1",
+          "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz"
+        }
+      }
+    },
+    "contentful": {
+      "version": "3.3.14",
+      "from": "contentful@^3.3.14",
+      "resolved": "https://registry.npmjs.org/contentful/-/contentful-3.3.14.tgz",
+      "dependencies": {
+        "babel-runtime": {
+          "version": "6.9.2",
+          "from": "babel-runtime@^6.3.19",
+          "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.9.2.tgz",
+          "dependencies": {
+            "core-js": {
+              "version": "2.4.0",
+              "from": "core-js@^2.4.0",
+              "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.4.0.tgz"
+            },
+            "regenerator-runtime": {
+              "version": "0.9.5",
+              "from": "regenerator-runtime@^0.9.5",
+              "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.5.tgz"
+            }
+          }
+        },
+        "contentful-sdk-core": {
+          "version": "2.2.4",
+          "from": "contentful-sdk-core@^2.2.1",
+          "resolved": "https://registry.npmjs.org/contentful-sdk-core/-/contentful-sdk-core-2.2.4.tgz",
+          "dependencies": {
+            "follow-redirects": {
+              "version": "0.0.7",
+              "from": "follow-redirects@0.0.7",
+              "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-0.0.7.tgz",
+              "dependencies": {
+                "debug": {
+                  "version": "2.2.0",
+                  "from": "debug@^2.2.0",
+                  "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+                  "dependencies": {
+                    "ms": {
+                      "version": "0.7.1",
+                      "from": "ms@0.7.1",
+                      "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+                    }
+                  }
+                },
+                "stream-consume": {
+                  "version": "0.1.0",
+                  "from": "stream-consume@^0.1.0",
+                  "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz"
+                }
+              }
+            },
+            "qs": {
+              "version": "6.2.0",
+              "from": "qs@^6.1.0",
+              "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.0.tgz"
+            }
+          }
+        },
+        "json-stringify-safe": {
+          "version": "5.0.1",
+          "from": "json-stringify-safe@^5.0.1",
+          "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
+        }
+      }
+    },
+    "cookie": {
+      "version": "0.2.4",
+      "from": "cookie@^0.2.3",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.2.4.tgz"
+    },
+    "cookie-parser": {
+      "version": "1.3.5",
+      "from": "cookie-parser@1.3.5",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.3.5.tgz",
+      "dependencies": {
+        "cookie": {
+          "version": "0.1.3",
+          "from": "cookie@0.1.3",
+          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
+        },
+        "cookie-signature": {
+          "version": "1.0.6",
+          "from": "cookie-signature@1.0.6",
+          "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+        }
+      }
+    },
+    "csurf": {
+      "version": "1.9.0",
+      "from": "csurf@^1.8.3",
+      "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.9.0.tgz",
+      "dependencies": {
+        "cookie": {
+          "version": "0.3.1",
+          "from": "cookie@0.3.1",
+          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz"
+        },
+        "cookie-signature": {
+          "version": "1.0.6",
+          "from": "cookie-signature@1.0.6",
+          "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+        },
+        "csrf": {
+          "version": "3.0.3",
+          "from": "csrf@~3.0.3",
+          "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.0.3.tgz",
+          "dependencies": {
+            "base64-url": {
+              "version": "1.2.2",
+              "from": "base64-url@1.2.2",
+              "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.2.tgz"
+            },
+            "rndm": {
+              "version": "1.2.0",
+              "from": "rndm@1.2.0",
+              "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz"
+            },
+            "tsscmp": {
+              "version": "1.0.5",
+              "from": "tsscmp@1.0.5",
+              "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.5.tgz"
+            },
+            "uid-safe": {
+              "version": "2.1.1",
+              "from": "uid-safe@2.1.1",
+              "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.1.tgz",
+              "dependencies": {
+                "random-bytes": {
+                  "version": "1.0.0",
+                  "from": "random-bytes@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz"
+                }
+              }
+            }
+          }
+        },
+        "http-errors": {
+          "version": "1.5.0",
+          "from": "http-errors@~1.5.0",
+          "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.0.tgz",
+          "dependencies": {
+            "inherits": {
+              "version": "2.0.1",
+              "from": "inherits@2.0.1",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+            },
+            "setprototypeof": {
+              "version": "1.0.1",
+              "from": "setprototypeof@1.0.1",
+              "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.1.tgz"
+            },
+            "statuses": {
+              "version": "1.3.0",
+              "from": "statuses@>= 1.3.0 < 2",
+              "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz"
+            }
+          }
+        }
+      }
+    },
+    "dateformat": {
+      "version": "1.0.4-1.2.3",
+      "from": "dateformat@1.0.4-1.2.3",
+      "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.4-1.2.3.tgz"
+    },
+    "express": {
+      "version": "4.13.0",
+      "from": "express@4.13.0",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.13.0.tgz",
+      "dependencies": {
+        "accepts": {
+          "version": "1.2.13",
+          "from": "accepts@~1.2.9",
+          "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.2.13.tgz",
+          "dependencies": {
+            "mime-types": {
+              "version": "2.1.11",
+              "from": "mime-types@~2.1.11",
+              "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+              "dependencies": {
+                "mime-db": {
+                  "version": "1.23.0",
+                  "from": "mime-db@~1.23.0",
+                  "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+                }
+              }
+            },
+            "negotiator": {
+              "version": "0.5.3",
+              "from": "negotiator@0.5.3",
+              "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.5.3.tgz"
+            }
+          }
+        },
+        "array-flatten": {
+          "version": "1.1.0",
+          "from": "array-flatten@1.1.0",
+          "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.0.tgz"
+        },
+        "content-disposition": {
+          "version": "0.5.0",
+          "from": "content-disposition@0.5.0",
+          "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.0.tgz"
+        },
+        "content-type": {
+          "version": "1.0.2",
+          "from": "content-type@~1.0.1",
+          "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz"
+        },
+        "cookie": {
+          "version": "0.1.3",
+          "from": "cookie@0.1.3",
+          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
+        },
+        "cookie-signature": {
+          "version": "1.0.6",
+          "from": "cookie-signature@1.0.6",
+          "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+        },
+        "debug": {
+          "version": "2.2.0",
+          "from": "debug@~2.2.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "dependencies": {
+            "ms": {
+              "version": "0.7.1",
+              "from": "ms@0.7.1",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+            }
+          }
+        },
+        "depd": {
+          "version": "1.0.1",
+          "from": "depd@~1.0.1",
+          "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz"
+        },
+        "escape-html": {
+          "version": "1.0.2",
+          "from": "escape-html@1.0.2",
+          "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.2.tgz"
+        },
+        "etag": {
+          "version": "1.7.0",
+          "from": "etag@~1.7.0",
+          "resolved": "https://registry.npmjs.org/etag/-/etag-1.7.0.tgz"
+        },
+        "finalhandler": {
+          "version": "0.4.0",
+          "from": "finalhandler@0.4.0",
+          "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.4.0.tgz",
+          "dependencies": {
+            "unpipe": {
+              "version": "1.0.0",
+              "from": "unpipe@~1.0.0",
+              "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
+            }
+          }
+        },
+        "fresh": {
+          "version": "0.3.0",
+          "from": "fresh@0.3.0",
+          "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz"
+        },
+        "merge-descriptors": {
+          "version": "1.0.0",
+          "from": "merge-descriptors@1.0.0",
+          "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.0.tgz"
+        },
+        "methods": {
+          "version": "1.1.2",
+          "from": "methods@~1.1.1",
+          "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
+        },
+        "on-finished": {
+          "version": "2.3.0",
+          "from": "on-finished@~2.3.0",
+          "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+          "dependencies": {
+            "ee-first": {
+              "version": "1.1.1",
+              "from": "ee-first@1.1.1",
+              "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
+            }
+          }
+        },
+        "parseurl": {
+          "version": "1.3.1",
+          "from": "parseurl@~1.3.0",
+          "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
+        },
+        "path-to-regexp": {
+          "version": "0.1.6",
+          "from": "path-to-regexp@0.1.6",
+          "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.6.tgz"
+        },
+        "proxy-addr": {
+          "version": "1.0.10",
+          "from": "proxy-addr@~1.0.8",
+          "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.0.10.tgz",
+          "dependencies": {
+            "forwarded": {
+              "version": "0.1.0",
+              "from": "forwarded@~0.1.0",
+              "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz"
+            },
+            "ipaddr.js": {
+              "version": "1.0.5",
+              "from": "ipaddr.js@1.0.5",
+              "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.0.5.tgz"
+            }
+          }
+        },
+        "qs": {
+          "version": "2.4.2",
+          "from": "qs@2.4.2",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-2.4.2.tgz"
+        },
+        "range-parser": {
+          "version": "1.0.3",
+          "from": "range-parser@~1.0.2",
+          "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.0.3.tgz"
+        },
+        "send": {
+          "version": "0.13.0",
+          "from": "send@0.13.0",
+          "resolved": "https://registry.npmjs.org/send/-/send-0.13.0.tgz",
+          "dependencies": {
+            "destroy": {
+              "version": "1.0.3",
+              "from": "destroy@1.0.3",
+              "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.3.tgz"
+            },
+            "http-errors": {
+              "version": "1.3.1",
+              "from": "http-errors@~1.3.1",
+              "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz",
+              "dependencies": {
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@~2.0.1",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                }
+              }
+            },
+            "mime": {
+              "version": "1.3.4",
+              "from": "mime@1.3.4",
+              "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz"
+            },
+            "ms": {
+              "version": "0.7.1",
+              "from": "ms@0.7.1",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+            },
+            "statuses": {
+              "version": "1.2.1",
+              "from": "statuses@~1.2.1",
+              "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz"
+            }
+          }
+        },
+        "serve-static": {
+          "version": "1.10.3",
+          "from": "serve-static@~1.10.0",
+          "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.10.3.tgz",
+          "dependencies": {
+            "escape-html": {
+              "version": "1.0.3",
+              "from": "escape-html@~1.0.3",
+              "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
+            },
+            "send": {
+              "version": "0.13.2",
+              "from": "send@0.13.2",
+              "resolved": "https://registry.npmjs.org/send/-/send-0.13.2.tgz",
+              "dependencies": {
+                "depd": {
+                  "version": "1.1.0",
+                  "from": "depd@~1.1.0",
+                  "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz"
+                },
+                "destroy": {
+                  "version": "1.0.4",
+                  "from": "destroy@~1.0.4",
+                  "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz"
+                },
+                "http-errors": {
+                  "version": "1.3.1",
+                  "from": "http-errors@~1.3.1",
+                  "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz",
+                  "dependencies": {
+                    "inherits": {
+                      "version": "2.0.1",
+                      "from": "inherits@~2.0.1",
+                      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                    }
+                  }
+                },
+                "mime": {
+                  "version": "1.3.4",
+                  "from": "mime@1.3.4",
+                  "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz"
+                },
+                "ms": {
+                  "version": "0.7.1",
+                  "from": "ms@0.7.1",
+                  "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+                },
+                "statuses": {
+                  "version": "1.2.1",
+                  "from": "statuses@~1.2.1",
+                  "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "type-is": {
+          "version": "1.6.13",
+          "from": "type-is@~1.6.3",
+          "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.13.tgz",
+          "dependencies": {
+            "media-typer": {
+              "version": "0.3.0",
+              "from": "media-typer@0.3.0",
+              "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
+            },
+            "mime-types": {
+              "version": "2.1.11",
+              "from": "mime-types@~2.1.11",
+              "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+              "dependencies": {
+                "mime-db": {
+                  "version": "1.23.0",
+                  "from": "mime-db@~1.23.0",
+                  "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+                }
+              }
+            }
+          }
+        },
+        "vary": {
+          "version": "1.0.1",
+          "from": "vary@~1.0.0",
+          "resolved": "https://registry.npmjs.org/vary/-/vary-1.0.1.tgz"
+        },
+        "utils-merge": {
+          "version": "1.0.0",
+          "from": "utils-merge@1.0.0",
+          "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
+        }
+      }
+    },
+    "express-session": {
+      "version": "1.11.3",
+      "from": "express-session@1.11.3",
+      "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz",
+      "dependencies": {
+        "cookie": {
+          "version": "0.1.3",
+          "from": "cookie@0.1.3",
+          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
+        },
+        "cookie-signature": {
+          "version": "1.0.6",
+          "from": "cookie-signature@1.0.6",
+          "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+        },
+        "crc": {
+          "version": "3.3.0",
+          "from": "crc@3.3.0",
+          "resolved": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz"
+        },
+        "debug": {
+          "version": "2.2.0",
+          "from": "debug@~2.2.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "dependencies": {
+            "ms": {
+              "version": "0.7.1",
+              "from": "ms@0.7.1",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+            }
+          }
+        },
+        "depd": {
+          "version": "1.0.1",
+          "from": "depd@~1.0.1",
+          "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz"
+        },
+        "on-headers": {
+          "version": "1.0.1",
+          "from": "on-headers@~1.0.0",
+          "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz"
+        },
+        "parseurl": {
+          "version": "1.3.1",
+          "from": "parseurl@~1.3.0",
+          "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
+        },
+        "uid-safe": {
+          "version": "2.0.0",
+          "from": "uid-safe@~2.0.0",
+          "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz",
+          "dependencies": {
+            "base64-url": {
+              "version": "1.2.1",
+              "from": "base64-url@1.2.1",
+              "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz"
+            }
+          }
+        },
+        "utils-merge": {
+          "version": "1.0.0",
+          "from": "utils-merge@1.0.0",
+          "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
+        }
+      }
+    },
+    "grunt": {
+      "version": "0.4.5",
+      "from": "grunt@^0.4.5",
+      "resolved": "https://registry.npmjs.org/grunt/-/grunt-0.4.5.tgz",
+      "dependencies": {
+        "async": {
+          "version": "0.1.22",
+          "from": "async@~0.1.22",
+          "resolved": "https://registry.npmjs.org/async/-/async-0.1.22.tgz"
+        },
+        "coffee-script": {
+          "version": "1.3.3",
+          "from": "coffee-script@~1.3.3",
+          "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.3.3.tgz"
+        },
+        "colors": {
+          "version": "0.6.2",
+          "from": "colors@~0.6.2",
+          "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz"
+        },
+        "dateformat": {
+          "version": "1.0.2-1.2.3",
+          "from": "dateformat@1.0.2-1.2.3",
+          "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.2-1.2.3.tgz"
+        },
+        "eventemitter2": {
+          "version": "0.4.14",
+          "from": "eventemitter2@~0.4.13",
+          "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz"
+        },
+        "findup-sync": {
+          "version": "0.1.3",
+          "from": "findup-sync@~0.1.2",
+          "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.1.3.tgz",
+          "dependencies": {
+            "glob": {
+              "version": "3.2.11",
+              "from": "glob@~3.2.9",
+              "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz",
+              "dependencies": {
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@2",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                },
+                "minimatch": {
+                  "version": "0.3.0",
+                  "from": "minimatch@0.3",
+                  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz",
+                  "dependencies": {
+                    "lru-cache": {
+                      "version": "2.7.3",
+                      "from": "lru-cache@2",
+                      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+                    },
+                    "sigmund": {
+                      "version": "1.0.1",
+                      "from": "sigmund@~1.0.0",
+                      "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+                    }
+                  }
+                }
+              }
+            },
+            "lodash": {
+              "version": "2.4.2",
+              "from": "lodash@~2.4.1",
+              "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+            }
+          }
+        },
+        "glob": {
+          "version": "3.1.21",
+          "from": "glob@~3.1.21",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz",
+          "dependencies": {
+            "graceful-fs": {
+              "version": "1.2.3",
+              "from": "graceful-fs@~1.2.0",
+              "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz"
+            },
+            "inherits": {
+              "version": "1.0.2",
+              "from": "inherits@1",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz"
+            }
+          }
+        },
+        "hooker": {
+          "version": "0.2.3",
+          "from": "hooker@~0.2.3",
+          "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz"
+        },
+        "iconv-lite": {
+          "version": "0.2.11",
+          "from": "iconv-lite@~0.2.11",
+          "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.2.11.tgz"
+        },
+        "minimatch": {
+          "version": "0.2.14",
+          "from": "minimatch@~0.2.12",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+          "dependencies": {
+            "lru-cache": {
+              "version": "2.7.3",
+              "from": "lru-cache@2",
+              "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+            },
+            "sigmund": {
+              "version": "1.0.1",
+              "from": "sigmund@~1.0.0",
+              "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+            }
+          }
+        },
+        "nopt": {
+          "version": "1.0.10",
+          "from": "nopt@~1.0.10",
+          "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
+          "dependencies": {
+            "abbrev": {
+              "version": "1.0.7",
+              "from": "abbrev@1",
+              "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.7.tgz"
+            }
+          }
+        },
+        "rimraf": {
+          "version": "2.2.8",
+          "from": "rimraf@~2.2.8",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz"
+        },
+        "lodash": {
+          "version": "0.9.2",
+          "from": "lodash@~0.9.2",
+          "resolved": "https://registry.npmjs.org/lodash/-/lodash-0.9.2.tgz"
+        },
+        "underscore.string": {
+          "version": "2.2.1",
+          "from": "underscore.string@~2.2.1",
+          "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.2.1.tgz"
+        },
+        "which": {
+          "version": "1.0.9",
+          "from": "which@~1.0.5",
+          "resolved": "https://registry.npmjs.org/which/-/which-1.0.9.tgz"
+        },
+        "js-yaml": {
+          "version": "2.0.5",
+          "from": "js-yaml@~2.0.5",
+          "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-2.0.5.tgz",
+          "dependencies": {
+            "argparse": {
+              "version": "0.1.16",
+              "from": "argparse@~ 0.1.11",
+              "resolved": "https://registry.npmjs.org/argparse/-/argparse-0.1.16.tgz",
+              "dependencies": {
+                "underscore": {
+                  "version": "1.7.0",
+                  "from": "underscore@~1.7.0",
+                  "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz"
+                },
+                "underscore.string": {
+                  "version": "2.4.0",
+                  "from": "underscore.string@~2.4.0",
+                  "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.4.0.tgz"
+                }
+              }
+            },
+            "esprima": {
+              "version": "1.0.4",
+              "from": "esprima@~ 1.0.2",
+              "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz"
+            }
+          }
+        },
+        "exit": {
+          "version": "0.1.2",
+          "from": "exit@~0.1.1",
+          "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz"
+        },
+        "getobject": {
+          "version": "0.1.0",
+          "from": "getobject@~0.1.0",
+          "resolved": "https://registry.npmjs.org/getobject/-/getobject-0.1.0.tgz"
+        },
+        "grunt-legacy-util": {
+          "version": "0.2.0",
+          "from": "grunt-legacy-util@~0.2.0",
+          "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-0.2.0.tgz"
+        },
+        "grunt-legacy-log": {
+          "version": "0.1.3",
+          "from": "grunt-legacy-log@~0.1.0",
+          "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-0.1.3.tgz",
+          "dependencies": {
+            "grunt-legacy-log-utils": {
+              "version": "0.1.1",
+              "from": "grunt-legacy-log-utils@~0.1.1",
+              "resolved": "https://registry.npmjs.org/grunt-legacy-log-utils/-/grunt-legacy-log-utils-0.1.1.tgz"
+            },
+            "lodash": {
+              "version": "2.4.2",
+              "from": "lodash@~2.4.1",
+              "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+            },
+            "underscore.string": {
+              "version": "2.3.3",
+              "from": "underscore.string@~2.3.3",
+              "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.3.3.tgz"
+            }
+          }
+        }
+      }
+    },
+    "heapdump": {
+      "version": "0.3.7",
+      "from": "heapdump@^0.3.7",
+      "resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.7.tgz"
+    },
+    "http-proxy": {
+      "version": "1.13.3",
+      "from": "http-proxy@^1.8.1",
+      "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.13.3.tgz",
+      "dependencies": {
+        "eventemitter3": {
+          "version": "1.2.0",
+          "from": "eventemitter3@1.x.x",
+          "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz"
+        },
+        "requires-port": {
+          "version": "1.0.0",
+          "from": "requires-port@1.x.x",
+          "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
+        }
+      }
+    },
+    "jade": {
+      "version": "1.3.1",
+      "from": "jade@~1.3.1",
+      "resolved": "https://registry.npmjs.org/jade/-/jade-1.3.1.tgz",
+      "dependencies": {
+        "commander": {
+          "version": "2.1.0",
+          "from": "commander@2.1.0",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-2.1.0.tgz"
+        },
+        "mkdirp": {
+          "version": "0.3.5",
+          "from": "mkdirp@~0.3.5",
+          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+        },
+        "transformers": {
+          "version": "2.1.0",
+          "from": "transformers@2.1.0",
+          "resolved": "https://registry.npmjs.org/transformers/-/transformers-2.1.0.tgz",
+          "dependencies": {
+            "promise": {
+              "version": "2.0.0",
+              "from": "promise@~2.0",
+              "resolved": "https://registry.npmjs.org/promise/-/promise-2.0.0.tgz",
+              "dependencies": {
+                "is-promise": {
+                  "version": "1.0.1",
+                  "from": "is-promise@~1",
+                  "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz"
+                }
+              }
+            },
+            "css": {
+              "version": "1.0.8",
+              "from": "css@~1.0.8",
+              "resolved": "https://registry.npmjs.org/css/-/css-1.0.8.tgz",
+              "dependencies": {
+                "css-parse": {
+                  "version": "1.0.4",
+                  "from": "css-parse@1.0.4",
+                  "resolved": "https://registry.npmjs.org/css-parse/-/css-parse-1.0.4.tgz"
+                },
+                "css-stringify": {
+                  "version": "1.0.5",
+                  "from": "css-stringify@1.0.5",
+                  "resolved": "https://registry.npmjs.org/css-stringify/-/css-stringify-1.0.5.tgz"
+                }
+              }
+            },
+            "uglify-js": {
+              "version": "2.2.5",
+              "from": "uglify-js@~2.2.5",
+              "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.2.5.tgz",
+              "dependencies": {
+                "source-map": {
+                  "version": "0.1.43",
+                  "from": "source-map@~0.1.7",
+                  "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz",
+                  "dependencies": {
+                    "amdefine": {
+                      "version": "1.0.0",
+                      "from": "amdefine@>=0.0.4",
+                      "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz"
+                    }
+                  }
+                },
+                "optimist": {
+                  "version": "0.3.7",
+                  "from": "optimist@~0.3.5",
+                  "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz",
+                  "dependencies": {
+                    "wordwrap": {
+                      "version": "0.0.3",
+                      "from": "wordwrap@~0.0.2",
+                      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        },
+        "character-parser": {
+          "version": "1.2.0",
+          "from": "character-parser@1.2.0",
+          "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-1.2.0.tgz"
+        },
+        "monocle": {
+          "version": "1.1.51",
+          "from": "monocle@1.1.51",
+          "resolved": "https://registry.npmjs.org/monocle/-/monocle-1.1.51.tgz",
+          "dependencies": {
+            "readdirp": {
+              "version": "0.2.5",
+              "from": "readdirp@~0.2.3",
+              "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-0.2.5.tgz",
+              "dependencies": {
+                "minimatch": {
+                  "version": "3.0.0",
+                  "from": "minimatch@>=0.2.4",
+                  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+                  "dependencies": {
+                    "brace-expansion": {
+                      "version": "1.1.4",
+                      "from": "brace-expansion@^1.0.0",
+                      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+                      "dependencies": {
+                        "balanced-match": {
+                          "version": "0.4.1",
+                          "from": "balanced-match@^0.4.1",
+                          "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+                        },
+                        "concat-map": {
+                          "version": "0.0.1",
+                          "from": "concat-map@0.0.1",
+                          "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        },
+        "with": {
+          "version": "3.0.1",
+          "from": "with@~3.0.0",
+          "resolved": "https://registry.npmjs.org/with/-/with-3.0.1.tgz",
+          "dependencies": {
+            "uglify-js": {
+              "version": "2.4.24",
+              "from": "uglify-js@~2.4.12",
+              "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.4.24.tgz",
+              "dependencies": {
+                "async": {
+                  "version": "0.2.10",
+                  "from": "async@~0.2.6",
+                  "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz"
+                },
+                "source-map": {
+                  "version": "0.1.34",
+                  "from": "source-map@0.1.34",
+                  "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.34.tgz",
+                  "dependencies": {
+                    "amdefine": {
+                      "version": "1.0.0",
+                      "from": "amdefine@>=0.0.4",
+                      "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz"
+                    }
+                  }
+                },
+                "uglify-to-browserify": {
+                  "version": "1.0.2",
+                  "from": "uglify-to-browserify@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz"
+                },
+                "yargs": {
+                  "version": "3.5.4",
+                  "from": "yargs@~3.5.4",
+                  "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz",
+                  "dependencies": {
+                    "camelcase": {
+                      "version": "1.2.1",
+                      "from": "camelcase@^1.0.2",
+                      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz"
+                    },
+                    "decamelize": {
+                      "version": "1.2.0",
+                      "from": "decamelize@^1.0.0",
+                      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
+                    },
+                    "window-size": {
+                      "version": "0.1.0",
+                      "from": "window-size@0.1.0",
+                      "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz"
+                    },
+                    "wordwrap": {
+                      "version": "0.0.2",
+                      "from": "wordwrap@0.0.2",
+                      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        },
+        "constantinople": {
+          "version": "2.0.1",
+          "from": "constantinople@~2.0.0",
+          "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-2.0.1.tgz",
+          "dependencies": {
+            "uglify-js": {
+              "version": "2.4.24",
+              "from": "uglify-js@~2.4.0",
+              "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.4.24.tgz",
+              "dependencies": {
+                "async": {
+                  "version": "0.2.10",
+                  "from": "async@~0.2.6",
+                  "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz"
+                },
+                "source-map": {
+                  "version": "0.1.34",
+                  "from": "source-map@0.1.34",
+                  "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.34.tgz",
+                  "dependencies": {
+                    "amdefine": {
+                      "version": "1.0.0",
+                      "from": "amdefine@>=0.0.4",
+                      "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz"
+                    }
+                  }
+                },
+                "uglify-to-browserify": {
+                  "version": "1.0.2",
+                  "from": "uglify-to-browserify@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz"
+                },
+                "yargs": {
+                  "version": "3.5.4",
+                  "from": "yargs@~3.5.4",
+                  "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz",
+                  "dependencies": {
+                    "camelcase": {
+                      "version": "1.2.1",
+                      "from": "camelcase@^1.0.2",
+                      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz"
+                    },
+                    "decamelize": {
+                      "version": "1.2.0",
+                      "from": "decamelize@^1.0.0",
+                      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
+                    },
+                    "window-size": {
+                      "version": "0.1.0",
+                      "from": "window-size@0.1.0",
+                      "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz"
+                    },
+                    "wordwrap": {
+                      "version": "0.0.2",
+                      "from": "wordwrap@0.0.2",
+                      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "ldapjs": {
+      "version": "1.0.0",
+      "from": "ldapjs@^1.0.0",
+      "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-1.0.0.tgz",
+      "dependencies": {
+        "asn1": {
+          "version": "0.2.3",
+          "from": "asn1@0.2.3",
+          "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz"
+        },
+        "assert-plus": {
+          "version": "0.1.5",
+          "from": "assert-plus@0.1.5",
+          "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz"
+        },
+        "bunyan": {
+          "version": "1.5.1",
+          "from": "bunyan@1.5.1",
+          "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.5.1.tgz",
+          "dependencies": {
+            "mv": {
+              "version": "2.1.1",
+              "from": "mv@~2",
+              "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
+              "dependencies": {
+                "mkdirp": {
+                  "version": "0.5.1",
+                  "from": "mkdirp@~0.5.1",
+                  "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+                  "dependencies": {
+                    "minimist": {
+                      "version": "0.0.8",
+                      "from": "minimist@0.0.8",
+                      "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+                    }
+                  }
+                },
+                "ncp": {
+                  "version": "2.0.0",
+                  "from": "ncp@~2.0.0",
+                  "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz"
+                },
+                "rimraf": {
+                  "version": "2.4.5",
+                  "from": "rimraf@~2.4.0",
+                  "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
+                  "dependencies": {
+                    "glob": {
+                      "version": "6.0.4",
+                      "from": "glob@^6.0.1",
+                      "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
+                      "dependencies": {
+                        "inflight": {
+                          "version": "1.0.5",
+                          "from": "inflight@^1.0.4",
+                          "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+                          "dependencies": {
+                            "wrappy": {
+                              "version": "1.0.2",
+                              "from": "wrappy@1",
+                              "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                            }
+                          }
+                        },
+                        "inherits": {
+                          "version": "2.0.1",
+                          "from": "inherits@2",
+                          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                        },
+                        "minimatch": {
+                          "version": "3.0.0",
+                          "from": "minimatch@2 || 3",
+                          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+                          "dependencies": {
+                            "brace-expansion": {
+                              "version": "1.1.4",
+                              "from": "brace-expansion@^1.0.0",
+                              "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+                              "dependencies": {
+                                "balanced-match": {
+                                  "version": "0.4.1",
+                                  "from": "balanced-match@^0.4.1",
+                                  "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+                                },
+                                "concat-map": {
+                                  "version": "0.0.1",
+                                  "from": "concat-map@0.0.1",
+                                  "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+                                }
+                              }
+                            }
+                          }
+                        },
+                        "path-is-absolute": {
+                          "version": "1.0.0",
+                          "from": "path-is-absolute@^1.0.0",
+                          "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            },
+            "safe-json-stringify": {
+              "version": "1.0.3",
+              "from": "safe-json-stringify@~1",
+              "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.3.tgz"
+            }
+          }
+        },
+        "dashdash": {
+          "version": "1.10.1",
+          "from": "dashdash@1.10.1",
+          "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.10.1.tgz"
+        },
+        "backoff": {
+          "version": "2.4.1",
+          "from": "backoff@2.4.1",
+          "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.4.1.tgz",
+          "dependencies": {
+            "precond": {
+              "version": "0.2.3",
+              "from": "precond@0.2",
+              "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz"
+            }
+          }
+        },
+        "ldap-filter": {
+          "version": "0.2.2",
+          "from": "ldap-filter@0.2.2",
+          "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz"
+        },
+        "once": {
+          "version": "1.3.2",
+          "from": "once@1.3.2",
+          "resolved": "https://registry.npmjs.org/once/-/once-1.3.2.tgz",
+          "dependencies": {
+            "wrappy": {
+              "version": "1.0.2",
+              "from": "wrappy@1",
+              "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+            }
+          }
+        },
+        "vasync": {
+          "version": "1.6.3",
+          "from": "vasync@1.6.3",
+          "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.3.tgz"
+        },
+        "verror": {
+          "version": "1.6.0",
+          "from": "verror@1.6.0",
+          "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz",
+          "dependencies": {
+            "extsprintf": {
+              "version": "1.2.0",
+              "from": "extsprintf@1.2.0",
+              "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz"
+            }
+          }
+        },
+        "dtrace-provider": {
+          "version": "0.6.0",
+          "from": "dtrace-provider@~0.6",
+          "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.6.0.tgz",
+          "dependencies": {
+            "nan": {
+              "version": "2.3.5",
+              "from": "nan@^2.0.8",
+              "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+            }
+          }
+        }
+      }
+    },
+    "lodash": {
+      "version": "4.13.1",
+      "from": "lodash@^4.13.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.13.1.tgz"
+    },
+    "logger-sharelatex": {
+      "version": "1.3.1",
+      "from": "logger-sharelatex@git+https://github.com/sharelatex/logger-sharelatex.git#v1.3.1",
+      "resolved": "git+https://github.com/sharelatex/logger-sharelatex.git#bf413ec621a000cf0e08c939de38d5e24541a08c",
+      "dependencies": {
+        "bunyan": {
+          "version": "1.5.1",
+          "from": "bunyan@1.5.1",
+          "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.5.1.tgz",
+          "dependencies": {
+            "dtrace-provider": {
+              "version": "0.6.0",
+              "from": "dtrace-provider@~0.6",
+              "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.6.0.tgz",
+              "dependencies": {
+                "nan": {
+                  "version": "2.3.5",
+                  "from": "nan@^2.0.8",
+                  "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+                }
+              }
+            },
+            "mv": {
+              "version": "2.1.1",
+              "from": "mv@~2",
+              "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
+              "dependencies": {
+                "mkdirp": {
+                  "version": "0.5.1",
+                  "from": "mkdirp@~0.5.1",
+                  "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+                  "dependencies": {
+                    "minimist": {
+                      "version": "0.0.8",
+                      "from": "minimist@0.0.8",
+                      "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+                    }
+                  }
+                },
+                "ncp": {
+                  "version": "2.0.0",
+                  "from": "ncp@~2.0.0",
+                  "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz"
+                },
+                "rimraf": {
+                  "version": "2.4.5",
+                  "from": "rimraf@~2.4.0",
+                  "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
+                  "dependencies": {
+                    "glob": {
+                      "version": "6.0.4",
+                      "from": "glob@^6.0.1",
+                      "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
+                      "dependencies": {
+                        "inflight": {
+                          "version": "1.0.5",
+                          "from": "inflight@^1.0.4",
+                          "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+                          "dependencies": {
+                            "wrappy": {
+                              "version": "1.0.2",
+                              "from": "wrappy@1",
+                              "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                            }
+                          }
+                        },
+                        "inherits": {
+                          "version": "2.0.1",
+                          "from": "inherits@2",
+                          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                        },
+                        "minimatch": {
+                          "version": "3.0.0",
+                          "from": "minimatch@2 || 3",
+                          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+                          "dependencies": {
+                            "brace-expansion": {
+                              "version": "1.1.4",
+                              "from": "brace-expansion@^1.0.0",
+                              "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+                              "dependencies": {
+                                "balanced-match": {
+                                  "version": "0.4.1",
+                                  "from": "balanced-match@^0.4.1",
+                                  "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+                                },
+                                "concat-map": {
+                                  "version": "0.0.1",
+                                  "from": "concat-map@0.0.1",
+                                  "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+                                }
+                              }
+                            }
+                          }
+                        },
+                        "once": {
+                          "version": "1.3.3",
+                          "from": "once@^1.3.0",
+                          "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+                          "dependencies": {
+                            "wrappy": {
+                              "version": "1.0.2",
+                              "from": "wrappy@1",
+                              "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                            }
+                          }
+                        },
+                        "path-is-absolute": {
+                          "version": "1.0.0",
+                          "from": "path-is-absolute@^1.0.0",
+                          "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            },
+            "safe-json-stringify": {
+              "version": "1.0.3",
+              "from": "safe-json-stringify@~1",
+              "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.3.tgz"
+            }
+          }
+        },
+        "coffee-script": {
+          "version": "1.4.0",
+          "from": "coffee-script@1.4.0",
+          "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.4.0.tgz"
+        },
+        "raven": {
+          "version": "0.8.1",
+          "from": "raven@^0.8.0",
+          "resolved": "https://registry.npmjs.org/raven/-/raven-0.8.1.tgz",
+          "dependencies": {
+            "cookie": {
+              "version": "0.1.0",
+              "from": "cookie@0.1.0",
+              "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.0.tgz"
+            },
+            "lsmod": {
+              "version": "0.0.3",
+              "from": "lsmod@~0.0.3",
+              "resolved": "https://registry.npmjs.org/lsmod/-/lsmod-0.0.3.tgz"
+            },
+            "stack-trace": {
+              "version": "0.0.7",
+              "from": "stack-trace@0.0.7",
+              "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.7.tgz"
+            }
+          }
+        }
+      }
+    },
+    "lynx": {
+      "version": "0.1.1",
+      "from": "lynx@0.1.1",
+      "resolved": "https://registry.npmjs.org/lynx/-/lynx-0.1.1.tgz",
+      "dependencies": {
+        "mersenne": {
+          "version": "0.0.3",
+          "from": "mersenne@~0.0.3",
+          "resolved": "https://registry.npmjs.org/mersenne/-/mersenne-0.0.3.tgz"
+        },
+        "statsd-parser": {
+          "version": "0.0.4",
+          "from": "statsd-parser@~0.0.4",
+          "resolved": "https://registry.npmjs.org/statsd-parser/-/statsd-parser-0.0.4.tgz"
+        }
+      }
+    },
+    "marked": {
+      "version": "0.3.5",
+      "from": "marked@^0.3.5",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.5.tgz"
+    },
+    "method-override": {
+      "version": "2.3.6",
+      "from": "method-override@^2.3.3",
+      "resolved": "https://registry.npmjs.org/method-override/-/method-override-2.3.6.tgz",
+      "dependencies": {
+        "debug": {
+          "version": "2.2.0",
+          "from": "debug@~2.2.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "dependencies": {
+            "ms": {
+              "version": "0.7.1",
+              "from": "ms@0.7.1",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+            }
+          }
+        },
+        "methods": {
+          "version": "1.1.2",
+          "from": "methods@~1.1.2",
+          "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
+        },
+        "parseurl": {
+          "version": "1.3.1",
+          "from": "parseurl@~1.3.1",
+          "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
+        },
+        "vary": {
+          "version": "1.1.0",
+          "from": "vary@~1.1.0",
+          "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.0.tgz"
+        }
+      }
+    },
+    "metrics-sharelatex": {
+      "version": "1.3.0",
+      "from": "metrics-sharelatex@git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
+      "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#080c4aeb696edcd5d6d86f202f2c528f0661d7a6",
+      "dependencies": {
+        "coffee-script": {
+          "version": "1.6.0",
+          "from": "coffee-script@1.6.0",
+          "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.0.tgz"
+        }
+      }
+    },
+    "mimelib": {
+      "version": "0.2.14",
+      "from": "mimelib@0.2.14",
+      "resolved": "https://registry.npmjs.org/mimelib/-/mimelib-0.2.14.tgz",
+      "dependencies": {
+        "encoding": {
+          "version": "0.1.12",
+          "from": "encoding@~0.1",
+          "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
+          "dependencies": {
+            "iconv-lite": {
+              "version": "0.4.13",
+              "from": "iconv-lite@~0.4.13",
+              "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz"
+            }
+          }
+        },
+        "addressparser": {
+          "version": "0.2.1",
+          "from": "addressparser@~0.2.0",
+          "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-0.2.1.tgz"
+        }
+      }
+    },
+    "mocha": {
+      "version": "1.17.1",
+      "from": "mocha@1.17.1",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-1.17.1.tgz",
+      "dependencies": {
+        "commander": {
+          "version": "2.0.0",
+          "from": "commander@2.0.0",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-2.0.0.tgz"
+        },
+        "growl": {
+          "version": "1.7.0",
+          "from": "growl@1.7.x",
+          "resolved": "https://registry.npmjs.org/growl/-/growl-1.7.0.tgz"
+        },
+        "jade": {
+          "version": "0.26.3",
+          "from": "jade@0.26.3",
+          "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz",
+          "dependencies": {
+            "commander": {
+              "version": "0.6.1",
+              "from": "commander@0.6.1",
+              "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz"
+            },
+            "mkdirp": {
+              "version": "0.3.0",
+              "from": "mkdirp@0.3.0",
+              "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz"
+            }
+          }
+        },
+        "diff": {
+          "version": "1.0.7",
+          "from": "diff@1.0.7",
+          "resolved": "https://registry.npmjs.org/diff/-/diff-1.0.7.tgz"
+        },
+        "debug": {
+          "version": "2.2.0",
+          "from": "debug@*",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "dependencies": {
+            "ms": {
+              "version": "0.7.1",
+              "from": "ms@0.7.1",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+            }
+          }
+        },
+        "mkdirp": {
+          "version": "0.3.5",
+          "from": "mkdirp@0.3.5",
+          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+        },
+        "glob": {
+          "version": "3.2.3",
+          "from": "glob@3.2.3",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz",
+          "dependencies": {
+            "minimatch": {
+              "version": "0.2.14",
+              "from": "minimatch@~0.2.11",
+              "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+              "dependencies": {
+                "lru-cache": {
+                  "version": "2.7.3",
+                  "from": "lru-cache@2",
+                  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+                },
+                "sigmund": {
+                  "version": "1.0.1",
+                  "from": "sigmund@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+                }
+              }
+            },
+            "graceful-fs": {
+              "version": "2.0.3",
+              "from": "graceful-fs@~2.0.0",
+              "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz"
+            },
+            "inherits": {
+              "version": "2.0.1",
+              "from": "inherits@2",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+            }
+          }
+        }
+      }
+    },
+    "mongojs": {
+      "version": "0.18.2",
+      "from": "mongojs@0.18.2",
+      "resolved": "https://registry.npmjs.org/mongojs/-/mongojs-0.18.2.tgz",
+      "dependencies": {
+        "thunky": {
+          "version": "0.1.0",
+          "from": "thunky@~0.1.0",
+          "resolved": "https://registry.npmjs.org/thunky/-/thunky-0.1.0.tgz"
+        },
+        "readable-stream": {
+          "version": "1.1.14",
+          "from": "readable-stream@1.1.x",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+          "dependencies": {
+            "core-util-is": {
+              "version": "1.0.2",
+              "from": "core-util-is@~1.0.0",
+              "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+            },
+            "isarray": {
+              "version": "0.0.1",
+              "from": "isarray@0.0.1",
+              "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+            },
+            "string_decoder": {
+              "version": "0.10.31",
+              "from": "string_decoder@~0.10.x",
+              "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+            },
+            "inherits": {
+              "version": "2.0.1",
+              "from": "inherits@~2.0.1",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+            }
+          }
+        },
+        "mongodb": {
+          "version": "1.4.32",
+          "from": "mongodb@1.4.32",
+          "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-1.4.32.tgz",
+          "dependencies": {
+            "bson": {
+              "version": "0.2.22",
+              "from": "bson@~0.2",
+              "resolved": "https://registry.npmjs.org/bson/-/bson-0.2.22.tgz",
+              "dependencies": {
+                "nan": {
+                  "version": "1.8.4",
+                  "from": "nan@~1.8",
+                  "resolved": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz"
+                }
+              }
+            },
+            "kerberos": {
+              "version": "0.0.9",
+              "from": "kerberos@0.0.9",
+              "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-0.0.9.tgz",
+              "dependencies": {
+                "nan": {
+                  "version": "1.6.2",
+                  "from": "nan@1.6.2",
+                  "resolved": "https://registry.npmjs.org/nan/-/nan-1.6.2.tgz"
+                }
+              }
+            },
+            "readable-stream": {
+              "version": "2.1.4",
+              "from": "readable-stream@latest",
+              "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.4.tgz",
+              "dependencies": {
+                "buffer-shims": {
+                  "version": "1.0.0",
+                  "from": "buffer-shims@^1.0.0",
+                  "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
+                },
+                "core-util-is": {
+                  "version": "1.0.2",
+                  "from": "core-util-is@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+                },
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@~2.0.1",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                },
+                "isarray": {
+                  "version": "1.0.0",
+                  "from": "isarray@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+                },
+                "process-nextick-args": {
+                  "version": "1.0.7",
+                  "from": "process-nextick-args@~1.0.6",
+                  "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+                },
+                "string_decoder": {
+                  "version": "0.10.31",
+                  "from": "string_decoder@~0.10.x",
+                  "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+                },
+                "util-deprecate": {
+                  "version": "1.0.2",
+                  "from": "util-deprecate@~1.0.1",
+                  "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "mongoose": {
+      "version": "4.1.0",
+      "from": "mongoose@4.1.0",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-4.1.0.tgz",
+      "dependencies": {
+        "async": {
+          "version": "0.9.0",
+          "from": "async@0.9.0",
+          "resolved": "https://registry.npmjs.org/async/-/async-0.9.0.tgz"
+        },
+        "bson": {
+          "version": "0.3.2",
+          "from": "bson@~0.3",
+          "resolved": "https://registry.npmjs.org/bson/-/bson-0.3.2.tgz",
+          "dependencies": {
+            "bson-ext": {
+              "version": "0.1.13",
+              "from": "bson-ext@~0.1",
+              "resolved": "https://registry.npmjs.org/bson-ext/-/bson-ext-0.1.13.tgz",
+              "dependencies": {
+                "bindings": {
+                  "version": "1.2.1",
+                  "from": "bindings@^1.2.1",
+                  "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz"
+                },
+                "nan": {
+                  "version": "2.0.9",
+                  "from": "nan@~2.0.9",
+                  "resolved": "https://registry.npmjs.org/nan/-/nan-2.0.9.tgz"
+                }
+              }
+            }
+          }
+        },
+        "hooks-fixed": {
+          "version": "1.1.0",
+          "from": "hooks-fixed@1.1.0",
+          "resolved": "https://registry.npmjs.org/hooks-fixed/-/hooks-fixed-1.1.0.tgz"
+        },
+        "kareem": {
+          "version": "1.0.1",
+          "from": "kareem@1.0.1",
+          "resolved": "https://registry.npmjs.org/kareem/-/kareem-1.0.1.tgz"
+        },
+        "mongodb": {
+          "version": "2.0.34",
+          "from": "mongodb@2.0.34",
+          "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.0.34.tgz",
+          "dependencies": {
+            "mongodb-core": {
+              "version": "1.2.0",
+              "from": "mongodb-core@1.2.0",
+              "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-1.2.0.tgz",
+              "dependencies": {
+                "bson": {
+                  "version": "0.4.23",
+                  "from": "bson@~0.4",
+                  "resolved": "https://registry.npmjs.org/bson/-/bson-0.4.23.tgz"
+                },
+                "kerberos": {
+                  "version": "0.0.21",
+                  "from": "kerberos@~0.0",
+                  "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-0.0.21.tgz",
+                  "dependencies": {
+                    "nan": {
+                      "version": "2.3.5",
+                      "from": "nan@~2.3",
+                      "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+                    }
+                  }
+                }
+              }
+            },
+            "readable-stream": {
+              "version": "1.0.31",
+              "from": "readable-stream@1.0.31",
+              "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.31.tgz",
+              "dependencies": {
+                "core-util-is": {
+                  "version": "1.0.2",
+                  "from": "core-util-is@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+                },
+                "isarray": {
+                  "version": "0.0.1",
+                  "from": "isarray@0.0.1",
+                  "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+                },
+                "string_decoder": {
+                  "version": "0.10.31",
+                  "from": "string_decoder@~0.10.x",
+                  "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+                },
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@~2.0.1",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "mpath": {
+          "version": "0.1.1",
+          "from": "mpath@0.1.1",
+          "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.1.1.tgz"
+        },
+        "mpromise": {
+          "version": "0.5.4",
+          "from": "mpromise@0.5.4",
+          "resolved": "https://registry.npmjs.org/mpromise/-/mpromise-0.5.4.tgz"
+        },
+        "mquery": {
+          "version": "1.6.1",
+          "from": "mquery@1.6.1",
+          "resolved": "https://registry.npmjs.org/mquery/-/mquery-1.6.1.tgz",
+          "dependencies": {
+            "bluebird": {
+              "version": "2.9.26",
+              "from": "bluebird@2.9.26",
+              "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.9.26.tgz"
+            },
+            "debug": {
+              "version": "2.2.0",
+              "from": "debug@2.2.0",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+              "dependencies": {
+                "ms": {
+                  "version": "0.7.1",
+                  "from": "ms@0.7.1",
+                  "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "ms": {
+          "version": "0.1.0",
+          "from": "ms@0.1.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-0.1.0.tgz"
+        },
+        "muri": {
+          "version": "1.0.0",
+          "from": "muri@1.0.0",
+          "resolved": "https://registry.npmjs.org/muri/-/muri-1.0.0.tgz"
+        },
+        "regexp-clone": {
+          "version": "0.0.1",
+          "from": "regexp-clone@0.0.1",
+          "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-0.0.1.tgz"
+        },
+        "sliced": {
+          "version": "0.0.5",
+          "from": "sliced@0.0.5",
+          "resolved": "https://registry.npmjs.org/sliced/-/sliced-0.0.5.tgz"
+        }
+      }
+    },
+    "multer": {
+      "version": "0.1.8",
+      "from": "multer@^0.1.8",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-0.1.8.tgz",
+      "dependencies": {
+        "busboy": {
+          "version": "0.2.13",
+          "from": "busboy@~0.2.9",
+          "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.13.tgz",
+          "dependencies": {
+            "dicer": {
+              "version": "0.2.5",
+              "from": "dicer@0.2.5",
+              "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
+              "dependencies": {
+                "streamsearch": {
+                  "version": "0.1.2",
+                  "from": "streamsearch@0.1.2",
+                  "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz"
+                }
+              }
+            },
+            "readable-stream": {
+              "version": "1.1.14",
+              "from": "readable-stream@1.1.x",
+              "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+              "dependencies": {
+                "core-util-is": {
+                  "version": "1.0.2",
+                  "from": "core-util-is@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+                },
+                "isarray": {
+                  "version": "0.0.1",
+                  "from": "isarray@0.0.1",
+                  "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+                },
+                "string_decoder": {
+                  "version": "0.10.31",
+                  "from": "string_decoder@~0.10.x",
+                  "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+                },
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@~2.0.1",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "mkdirp": {
+          "version": "0.3.5",
+          "from": "mkdirp@~0.3.5",
+          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+        },
+        "qs": {
+          "version": "1.2.2",
+          "from": "qs@~1.2.2",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-1.2.2.tgz"
+        },
+        "type-is": {
+          "version": "1.5.7",
+          "from": "type-is@~1.5.2",
+          "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.5.7.tgz",
+          "dependencies": {
+            "media-typer": {
+              "version": "0.3.0",
+              "from": "media-typer@0.3.0",
+              "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
+            },
+            "mime-types": {
+              "version": "2.0.14",
+              "from": "mime-types@~2.0.9",
+              "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.0.14.tgz",
+              "dependencies": {
+                "mime-db": {
+                  "version": "1.12.0",
+                  "from": "mime-db@~1.12.0",
+                  "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.12.0.tgz"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "node-uuid": {
+      "version": "1.4.1",
+      "from": "node-uuid@1.4.1",
+      "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.1.tgz"
+    },
+    "nodemailer": {
+      "version": "2.1.0",
+      "from": "nodemailer@2.1.0",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-2.1.0.tgz",
+      "dependencies": {
+        "libmime": {
+          "version": "2.0.0",
+          "from": "libmime@2.0.0",
+          "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.0.0.tgz",
+          "dependencies": {
+            "iconv-lite": {
+              "version": "0.4.13",
+              "from": "iconv-lite@0.4.13",
+              "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz"
+            },
+            "libbase64": {
+              "version": "0.1.0",
+              "from": "libbase64@0.1.0",
+              "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz"
+            },
+            "libqp": {
+              "version": "1.1.0",
+              "from": "libqp@1.1.0",
+              "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz"
+            }
+          }
+        },
+        "mailcomposer": {
+          "version": "3.3.2",
+          "from": "mailcomposer@3.3.2",
+          "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-3.3.2.tgz",
+          "dependencies": {
+            "buildmail": {
+              "version": "3.3.2",
+              "from": "buildmail@3.3.2",
+              "resolved": "https://registry.npmjs.org/buildmail/-/buildmail-3.3.2.tgz",
+              "dependencies": {
+                "addressparser": {
+                  "version": "1.0.0",
+                  "from": "addressparser@1.0.0",
+                  "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.0.tgz"
+                },
+                "libbase64": {
+                  "version": "0.1.0",
+                  "from": "libbase64@0.1.0",
+                  "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz"
+                },
+                "libqp": {
+                  "version": "1.1.0",
+                  "from": "libqp@1.1.0",
+                  "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz"
+                },
+                "nodemailer-fetch": {
+                  "version": "1.2.1",
+                  "from": "nodemailer-fetch@1.2.1",
+                  "resolved": "https://registry.npmjs.org/nodemailer-fetch/-/nodemailer-fetch-1.2.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "nodemailer-direct-transport": {
+          "version": "2.0.1",
+          "from": "nodemailer-direct-transport@2.0.1",
+          "resolved": "https://registry.npmjs.org/nodemailer-direct-transport/-/nodemailer-direct-transport-2.0.1.tgz",
+          "dependencies": {
+            "smtp-connection": {
+              "version": "2.0.1",
+              "from": "smtp-connection@2.0.1",
+              "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-2.0.1.tgz"
+            }
+          }
+        },
+        "nodemailer-shared": {
+          "version": "1.0.3",
+          "from": "nodemailer-shared@1.0.3",
+          "resolved": "https://registry.npmjs.org/nodemailer-shared/-/nodemailer-shared-1.0.3.tgz",
+          "dependencies": {
+            "nodemailer-fetch": {
+              "version": "1.2.1",
+              "from": "nodemailer-fetch@1.2.1",
+              "resolved": "https://registry.npmjs.org/nodemailer-fetch/-/nodemailer-fetch-1.2.1.tgz"
+            }
+          }
+        },
+        "nodemailer-smtp-pool": {
+          "version": "2.1.0",
+          "from": "nodemailer-smtp-pool@2.1.0",
+          "resolved": "https://registry.npmjs.org/nodemailer-smtp-pool/-/nodemailer-smtp-pool-2.1.0.tgz",
+          "dependencies": {
+            "clone": {
+              "version": "1.0.2",
+              "from": "clone@1.0.2",
+              "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz"
+            },
+            "nodemailer-wellknown": {
+              "version": "0.1.7",
+              "from": "nodemailer-wellknown@0.1.7",
+              "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.7.tgz"
+            },
+            "smtp-connection": {
+              "version": "2.0.1",
+              "from": "smtp-connection@2.0.1",
+              "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-2.0.1.tgz"
+            }
+          }
+        },
+        "nodemailer-smtp-transport": {
+          "version": "2.0.1",
+          "from": "nodemailer-smtp-transport@2.0.1",
+          "resolved": "https://registry.npmjs.org/nodemailer-smtp-transport/-/nodemailer-smtp-transport-2.0.1.tgz",
+          "dependencies": {
+            "clone": {
+              "version": "1.0.2",
+              "from": "clone@1.0.2",
+              "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz"
+            },
+            "nodemailer-wellknown": {
+              "version": "0.1.7",
+              "from": "nodemailer-wellknown@0.1.7",
+              "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.7.tgz"
+            },
+            "smtp-connection": {
+              "version": "2.0.1",
+              "from": "smtp-connection@2.0.1",
+              "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-2.0.1.tgz"
+            }
+          }
+        }
+      }
+    },
+    "nodemailer-sendgrid-transport": {
+      "version": "0.2.0",
+      "from": "nodemailer-sendgrid-transport@^0.2.0",
+      "resolved": "https://registry.npmjs.org/nodemailer-sendgrid-transport/-/nodemailer-sendgrid-transport-0.2.0.tgz",
+      "dependencies": {
+        "sendgrid": {
+          "version": "1.9.2",
+          "from": "sendgrid@^1.8.0",
+          "resolved": "https://registry.npmjs.org/sendgrid/-/sendgrid-1.9.2.tgz",
+          "dependencies": {
+            "mime": {
+              "version": "1.3.4",
+              "from": "mime@^1.2.9",
+              "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz"
+            },
+            "lodash": {
+              "version": "3.10.1",
+              "from": "lodash@^3.0.1 || ^2.0.0",
+              "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz"
+            },
+            "smtpapi": {
+              "version": "1.2.0",
+              "from": "smtpapi@^1.2.0",
+              "resolved": "https://registry.npmjs.org/smtpapi/-/smtpapi-1.2.0.tgz"
+            }
+          }
+        }
+      }
+    },
+    "nodemailer-ses-transport": {
+      "version": "1.3.1",
+      "from": "nodemailer-ses-transport@^1.3.0",
+      "resolved": "https://registry.npmjs.org/nodemailer-ses-transport/-/nodemailer-ses-transport-1.3.1.tgz",
+      "dependencies": {
+        "aws-sdk": {
+          "version": "2.4.0",
+          "from": "aws-sdk@^2.3.11",
+          "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.4.0.tgz",
+          "dependencies": {
+            "sax": {
+              "version": "1.1.5",
+              "from": "sax@1.1.5",
+              "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.5.tgz"
+            },
+            "xml2js": {
+              "version": "0.4.15",
+              "from": "xml2js@0.4.15",
+              "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.15.tgz"
+            },
+            "xmlbuilder": {
+              "version": "2.6.2",
+              "from": "xmlbuilder@2.6.2",
+              "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-2.6.2.tgz",
+              "dependencies": {
+                "lodash": {
+                  "version": "3.5.0",
+                  "from": "lodash@~3.5.0",
+                  "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.5.0.tgz"
+                }
+              }
+            },
+            "jmespath": {
+              "version": "0.15.0",
+              "from": "jmespath@0.15.0",
+              "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz"
+            }
+          }
+        }
+      }
+    },
+    "optimist": {
+      "version": "0.6.1",
+      "from": "optimist@0.6.1",
+      "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
+      "dependencies": {
+        "wordwrap": {
+          "version": "0.0.3",
+          "from": "wordwrap@~0.0.2",
+          "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz"
+        },
+        "minimist": {
+          "version": "0.0.10",
+          "from": "minimist@~0.0.1",
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz"
+        }
+      }
+    },
+    "redback": {
+      "version": "0.4.0",
+      "from": "redback@0.4.0",
+      "resolved": "https://registry.npmjs.org/redback/-/redback-0.4.0.tgz"
+    },
+    "redis": {
+      "version": "0.10.1",
+      "from": "redis@0.10.1",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-0.10.1.tgz"
+    },
+    "redis-sharelatex": {
+      "version": "0.0.9",
+      "from": "redis-sharelatex@0.0.9",
+      "resolved": "https://registry.npmjs.org/redis-sharelatex/-/redis-sharelatex-0.0.9.tgz",
+      "dependencies": {
+        "chai": {
+          "version": "1.9.1",
+          "from": "chai@1.9.1",
+          "resolved": "https://registry.npmjs.org/chai/-/chai-1.9.1.tgz",
+          "dependencies": {
+            "assertion-error": {
+              "version": "1.0.0",
+              "from": "assertion-error@1.0.0",
+              "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.0.tgz"
+            },
+            "deep-eql": {
+              "version": "0.1.3",
+              "from": "deep-eql@0.1.3",
+              "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz",
+              "dependencies": {
+                "type-detect": {
+                  "version": "0.1.1",
+                  "from": "type-detect@0.1.1",
+                  "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "coffee-script": {
+          "version": "1.8.0",
+          "from": "coffee-script@1.8.0",
+          "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.8.0.tgz",
+          "dependencies": {
+            "mkdirp": {
+              "version": "0.3.5",
+              "from": "mkdirp@~0.3.5",
+              "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+            }
+          }
+        },
+        "grunt-contrib-coffee": {
+          "version": "0.11.1",
+          "from": "grunt-contrib-coffee@0.11.1",
+          "resolved": "https://registry.npmjs.org/grunt-contrib-coffee/-/grunt-contrib-coffee-0.11.1.tgz",
+          "dependencies": {
+            "coffee-script": {
+              "version": "1.7.1",
+              "from": "coffee-script@~1.7.0",
+              "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.7.1.tgz",
+              "dependencies": {
+                "mkdirp": {
+                  "version": "0.3.5",
+                  "from": "mkdirp@~0.3.5",
+                  "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+                }
+              }
+            },
+            "chalk": {
+              "version": "0.5.1",
+              "from": "chalk@~0.5.0",
+              "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz",
+              "dependencies": {
+                "ansi-styles": {
+                  "version": "1.1.0",
+                  "from": "ansi-styles@^1.1.0",
+                  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz"
+                },
+                "escape-string-regexp": {
+                  "version": "1.0.5",
+                  "from": "escape-string-regexp@^1.0.0",
+                  "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
+                },
+                "has-ansi": {
+                  "version": "0.1.0",
+                  "from": "has-ansi@^0.1.0",
+                  "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz",
+                  "dependencies": {
+                    "ansi-regex": {
+                      "version": "0.2.1",
+                      "from": "ansi-regex@^0.2.0",
+                      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz"
+                    }
+                  }
+                },
+                "strip-ansi": {
+                  "version": "0.3.0",
+                  "from": "strip-ansi@^0.3.0",
+                  "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz",
+                  "dependencies": {
+                    "ansi-regex": {
+                      "version": "0.2.1",
+                      "from": "ansi-regex@^0.2.0",
+                      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz"
+                    }
+                  }
+                },
+                "supports-color": {
+                  "version": "0.2.0",
+                  "from": "supports-color@^0.2.0",
+                  "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz"
+                }
+              }
+            },
+            "lodash": {
+              "version": "2.4.2",
+              "from": "lodash@~2.4.1",
+              "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+            }
+          }
+        },
+        "grunt-mocha-test": {
+          "version": "0.12.0",
+          "from": "grunt-mocha-test@0.12.0",
+          "resolved": "https://registry.npmjs.org/grunt-mocha-test/-/grunt-mocha-test-0.12.0.tgz",
+          "dependencies": {
+            "hooker": {
+              "version": "0.2.3",
+              "from": "hooker@~0.2.3",
+              "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz"
+            },
+            "fs-extra": {
+              "version": "0.11.1",
+              "from": "fs-extra@~0.11.1",
+              "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.11.1.tgz",
+              "dependencies": {
+                "ncp": {
+                  "version": "0.6.0",
+                  "from": "ncp@^0.6.0",
+                  "resolved": "https://registry.npmjs.org/ncp/-/ncp-0.6.0.tgz"
+                },
+                "mkdirp": {
+                  "version": "0.5.1",
+                  "from": "mkdirp@^0.5.0",
+                  "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+                  "dependencies": {
+                    "minimist": {
+                      "version": "0.0.8",
+                      "from": "minimist@0.0.8",
+                      "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+                    }
+                  }
+                },
+                "jsonfile": {
+                  "version": "2.3.1",
+                  "from": "jsonfile@^2.0.0",
+                  "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.3.1.tgz"
+                },
+                "rimraf": {
+                  "version": "2.5.2",
+                  "from": "rimraf@^2.2.8",
+                  "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.2.tgz",
+                  "dependencies": {
+                    "glob": {
+                      "version": "7.0.3",
+                      "from": "glob@^7.0.0",
+                      "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.3.tgz",
+                      "dependencies": {
+                        "inflight": {
+                          "version": "1.0.5",
+                          "from": "inflight@^1.0.4",
+                          "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+                          "dependencies": {
+                            "wrappy": {
+                              "version": "1.0.2",
+                              "from": "wrappy@1",
+                              "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                            }
+                          }
+                        },
+                        "inherits": {
+                          "version": "2.0.1",
+                          "from": "inherits@2",
+                          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                        },
+                        "minimatch": {
+                          "version": "3.0.0",
+                          "from": "minimatch@2 || 3",
+                          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+                          "dependencies": {
+                            "brace-expansion": {
+                              "version": "1.1.4",
+                              "from": "brace-expansion@^1.0.0",
+                              "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+                              "dependencies": {
+                                "balanced-match": {
+                                  "version": "0.4.1",
+                                  "from": "balanced-match@^0.4.1",
+                                  "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+                                },
+                                "concat-map": {
+                                  "version": "0.0.1",
+                                  "from": "concat-map@0.0.1",
+                                  "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+                                }
+                              }
+                            }
+                          }
+                        },
+                        "once": {
+                          "version": "1.3.3",
+                          "from": "once@^1.3.0",
+                          "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+                          "dependencies": {
+                            "wrappy": {
+                              "version": "1.0.2",
+                              "from": "wrappy@1",
+                              "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                            }
+                          }
+                        },
+                        "path-is-absolute": {
+                          "version": "1.0.0",
+                          "from": "path-is-absolute@^1.0.0",
+                          "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        },
+        "mocha": {
+          "version": "1.21.4",
+          "from": "mocha@1.21.4",
+          "resolved": "https://registry.npmjs.org/mocha/-/mocha-1.21.4.tgz",
+          "dependencies": {
+            "commander": {
+              "version": "2.0.0",
+              "from": "commander@2.0.0",
+              "resolved": "https://registry.npmjs.org/commander/-/commander-2.0.0.tgz"
+            },
+            "growl": {
+              "version": "1.8.1",
+              "from": "growl@1.8.x",
+              "resolved": "https://registry.npmjs.org/growl/-/growl-1.8.1.tgz"
+            },
+            "jade": {
+              "version": "0.26.3",
+              "from": "jade@0.26.3",
+              "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz",
+              "dependencies": {
+                "commander": {
+                  "version": "0.6.1",
+                  "from": "commander@0.6.1",
+                  "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz"
+                },
+                "mkdirp": {
+                  "version": "0.3.0",
+                  "from": "mkdirp@0.3.0",
+                  "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz"
+                }
+              }
+            },
+            "diff": {
+              "version": "1.0.7",
+              "from": "diff@1.0.7",
+              "resolved": "https://registry.npmjs.org/diff/-/diff-1.0.7.tgz"
+            },
+            "debug": {
+              "version": "2.2.0",
+              "from": "debug@*",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+              "dependencies": {
+                "ms": {
+                  "version": "0.7.1",
+                  "from": "ms@0.7.1",
+                  "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+                }
+              }
+            },
+            "mkdirp": {
+              "version": "0.3.5",
+              "from": "mkdirp@0.3.5",
+              "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+            },
+            "glob": {
+              "version": "3.2.3",
+              "from": "glob@3.2.3",
+              "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz",
+              "dependencies": {
+                "minimatch": {
+                  "version": "0.2.14",
+                  "from": "minimatch@~0.2.11",
+                  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+                  "dependencies": {
+                    "lru-cache": {
+                      "version": "2.7.3",
+                      "from": "lru-cache@2",
+                      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+                    },
+                    "sigmund": {
+                      "version": "1.0.1",
+                      "from": "sigmund@~1.0.0",
+                      "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+                    }
+                  }
+                },
+                "graceful-fs": {
+                  "version": "2.0.3",
+                  "from": "graceful-fs@~2.0.0",
+                  "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz"
+                },
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@2",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "redis": {
+          "version": "0.12.1",
+          "from": "redis@0.12.1",
+          "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz"
+        },
+        "redis-sentinel": {
+          "version": "0.1.1",
+          "from": "redis-sentinel@0.1.1",
+          "resolved": "https://registry.npmjs.org/redis-sentinel/-/redis-sentinel-0.1.1.tgz",
+          "dependencies": {
+            "redis": {
+              "version": "0.11.0",
+              "from": "redis@0.11.x",
+              "resolved": "https://registry.npmjs.org/redis/-/redis-0.11.0.tgz"
+            },
+            "q": {
+              "version": "0.9.2",
+              "from": "q@0.9.2",
+              "resolved": "https://registry.npmjs.org/q/-/q-0.9.2.tgz"
+            }
+          }
+        },
+        "sandboxed-module": {
+          "version": "1.0.1",
+          "from": "sandboxed-module@1.0.1",
+          "resolved": "https://registry.npmjs.org/sandboxed-module/-/sandboxed-module-1.0.1.tgz",
+          "dependencies": {
+            "require-like": {
+              "version": "0.1.2",
+              "from": "require-like@0.1.2",
+              "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz"
+            },
+            "stack-trace": {
+              "version": "0.0.9",
+              "from": "stack-trace@0.0.9",
+              "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz"
+            }
+          }
+        },
+        "sinon": {
+          "version": "1.10.3",
+          "from": "sinon@1.10.3",
+          "resolved": "https://registry.npmjs.org/sinon/-/sinon-1.10.3.tgz",
+          "dependencies": {
+            "formatio": {
+              "version": "1.0.2",
+              "from": "formatio@~1.0",
+              "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.0.2.tgz",
+              "dependencies": {
+                "samsam": {
+                  "version": "1.1.3",
+                  "from": "samsam@~1.1",
+                  "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.3.tgz"
+                }
+              }
+            },
+            "util": {
+              "version": "0.10.3",
+              "from": "util@>=0.10.3 <1",
+              "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+              "dependencies": {
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@2.0.1",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "underscore": {
+          "version": "1.7.0",
+          "from": "underscore@1.7.0",
+          "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz"
+        }
+      }
+    },
+    "request": {
+      "version": "2.72.0",
+      "from": "request@^2.69.0",
+      "resolved": "https://registry.npmjs.org/request/-/request-2.72.0.tgz",
+      "dependencies": {
+        "aws-sign2": {
+          "version": "0.6.0",
+          "from": "aws-sign2@~0.6.0",
+          "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz"
+        },
+        "aws4": {
+          "version": "1.4.1",
+          "from": "aws4@^1.2.1",
+          "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.4.1.tgz"
+        },
+        "bl": {
+          "version": "1.1.2",
+          "from": "bl@~1.1.2",
+          "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz",
+          "dependencies": {
+            "readable-stream": {
+              "version": "2.0.6",
+              "from": "readable-stream@~2.0.5",
+              "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz",
+              "dependencies": {
+                "core-util-is": {
+                  "version": "1.0.2",
+                  "from": "core-util-is@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+                },
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@~2.0.1",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                },
+                "isarray": {
+                  "version": "1.0.0",
+                  "from": "isarray@~1.0.0",
+                  "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+                },
+                "process-nextick-args": {
+                  "version": "1.0.7",
+                  "from": "process-nextick-args@~1.0.6",
+                  "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+                },
+                "string_decoder": {
+                  "version": "0.10.31",
+                  "from": "string_decoder@~0.10.x",
+                  "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+                },
+                "util-deprecate": {
+                  "version": "1.0.2",
+                  "from": "util-deprecate@~1.0.1",
+                  "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+                }
+              }
+            }
+          }
+        },
+        "caseless": {
+          "version": "0.11.0",
+          "from": "caseless@~0.11.0",
+          "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz"
+        },
+        "combined-stream": {
+          "version": "1.0.5",
+          "from": "combined-stream@~1.0.5",
+          "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz",
+          "dependencies": {
+            "delayed-stream": {
+              "version": "1.0.0",
+              "from": "delayed-stream@~1.0.0",
+              "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
+            }
+          }
+        },
+        "extend": {
+          "version": "3.0.0",
+          "from": "extend@~3.0.0",
+          "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.0.tgz"
+        },
+        "forever-agent": {
+          "version": "0.6.1",
+          "from": "forever-agent@~0.6.1",
+          "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz"
+        },
+        "form-data": {
+          "version": "1.0.0-rc4",
+          "from": "form-data@~1.0.0-rc3",
+          "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.0-rc4.tgz",
+          "dependencies": {
+            "async": {
+              "version": "1.5.2",
+              "from": "async@^1.5.2",
+              "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz"
+            }
+          }
+        },
+        "har-validator": {
+          "version": "2.0.6",
+          "from": "har-validator@~2.0.6",
+          "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz",
+          "dependencies": {
+            "chalk": {
+              "version": "1.1.3",
+              "from": "chalk@^1.1.1",
+              "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+              "dependencies": {
+                "ansi-styles": {
+                  "version": "2.2.1",
+                  "from": "ansi-styles@^2.2.1",
+                  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz"
+                },
+                "escape-string-regexp": {
+                  "version": "1.0.5",
+                  "from": "escape-string-regexp@^1.0.2",
+                  "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
+                },
+                "has-ansi": {
+                  "version": "2.0.0",
+                  "from": "has-ansi@^2.0.0",
+                  "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+                  "dependencies": {
+                    "ansi-regex": {
+                      "version": "2.0.0",
+                      "from": "ansi-regex@^2.0.0",
+                      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz"
+                    }
+                  }
+                },
+                "strip-ansi": {
+                  "version": "3.0.1",
+                  "from": "strip-ansi@^3.0.0",
+                  "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+                  "dependencies": {
+                    "ansi-regex": {
+                      "version": "2.0.0",
+                      "from": "ansi-regex@^2.0.0",
+                      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz"
+                    }
+                  }
+                },
+                "supports-color": {
+                  "version": "2.0.0",
+                  "from": "supports-color@^2.0.0",
+                  "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz"
+                }
+              }
+            },
+            "commander": {
+              "version": "2.9.0",
+              "from": "commander@^2.9.0",
+              "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz",
+              "dependencies": {
+                "graceful-readlink": {
+                  "version": "1.0.1",
+                  "from": "graceful-readlink@>= 1.0.0",
+                  "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz"
+                }
+              }
+            },
+            "is-my-json-valid": {
+              "version": "2.13.1",
+              "from": "is-my-json-valid@^2.12.4",
+              "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.13.1.tgz",
+              "dependencies": {
+                "generate-function": {
+                  "version": "2.0.0",
+                  "from": "generate-function@^2.0.0",
+                  "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz"
+                },
+                "generate-object-property": {
+                  "version": "1.2.0",
+                  "from": "generate-object-property@^1.1.0",
+                  "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz",
+                  "dependencies": {
+                    "is-property": {
+                      "version": "1.0.2",
+                      "from": "is-property@^1.0.0",
+                      "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz"
+                    }
+                  }
+                },
+                "jsonpointer": {
+                  "version": "2.0.0",
+                  "from": "jsonpointer@2.0.0",
+                  "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-2.0.0.tgz"
+                },
+                "xtend": {
+                  "version": "4.0.1",
+                  "from": "xtend@^4.0.0",
+                  "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz"
+                }
+              }
+            },
+            "pinkie-promise": {
+              "version": "2.0.1",
+              "from": "pinkie-promise@^2.0.0",
+              "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+              "dependencies": {
+                "pinkie": {
+                  "version": "2.0.4",
+                  "from": "pinkie@^2.0.0",
+                  "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz"
+                }
+              }
+            }
+          }
+        },
+        "hawk": {
+          "version": "3.1.3",
+          "from": "hawk@~3.1.3",
+          "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz",
+          "dependencies": {
+            "hoek": {
+              "version": "2.16.3",
+              "from": "hoek@2.x.x",
+              "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz"
+            },
+            "boom": {
+              "version": "2.10.1",
+              "from": "boom@2.x.x",
+              "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz"
+            },
+            "cryptiles": {
+              "version": "2.0.5",
+              "from": "cryptiles@2.x.x",
+              "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz"
+            },
+            "sntp": {
+              "version": "1.0.9",
+              "from": "sntp@1.x.x",
+              "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz"
+            }
+          }
+        },
+        "http-signature": {
+          "version": "1.1.1",
+          "from": "http-signature@~1.1.0",
+          "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz",
+          "dependencies": {
+            "assert-plus": {
+              "version": "0.2.0",
+              "from": "assert-plus@^0.2.0",
+              "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz"
+            },
+            "jsprim": {
+              "version": "1.2.2",
+              "from": "jsprim@^1.2.2",
+              "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.2.2.tgz",
+              "dependencies": {
+                "extsprintf": {
+                  "version": "1.0.2",
+                  "from": "extsprintf@1.0.2",
+                  "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz"
+                },
+                "json-schema": {
+                  "version": "0.2.2",
+                  "from": "json-schema@0.2.2",
+                  "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.2.tgz"
+                },
+                "verror": {
+                  "version": "1.3.6",
+                  "from": "verror@1.3.6",
+                  "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz"
+                }
+              }
+            },
+            "sshpk": {
+              "version": "1.8.3",
+              "from": "sshpk@^1.7.0",
+              "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.8.3.tgz",
+              "dependencies": {
+                "asn1": {
+                  "version": "0.2.3",
+                  "from": "asn1@~0.2.3",
+                  "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz"
+                },
+                "assert-plus": {
+                  "version": "1.0.0",
+                  "from": "assert-plus@^1.0.0",
+                  "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz"
+                },
+                "dashdash": {
+                  "version": "1.14.0",
+                  "from": "dashdash@^1.12.0",
+                  "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.0.tgz"
+                },
+                "getpass": {
+                  "version": "0.1.6",
+                  "from": "getpass@^0.1.1",
+                  "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.6.tgz"
+                },
+                "jsbn": {
+                  "version": "0.1.0",
+                  "from": "jsbn@~0.1.0",
+                  "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.0.tgz"
+                },
+                "tweetnacl": {
+                  "version": "0.13.3",
+                  "from": "tweetnacl@~0.13.0",
+                  "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.13.3.tgz"
+                },
+                "jodid25519": {
+                  "version": "1.0.2",
+                  "from": "jodid25519@^1.0.0",
+                  "resolved": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz"
+                },
+                "ecc-jsbn": {
+                  "version": "0.1.1",
+                  "from": "ecc-jsbn@~0.1.1",
+                  "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz"
+                }
+              }
+            }
+          }
+        },
+        "is-typedarray": {
+          "version": "1.0.0",
+          "from": "is-typedarray@~1.0.0",
+          "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz"
+        },
+        "isstream": {
+          "version": "0.1.2",
+          "from": "isstream@~0.1.2",
+          "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz"
+        },
+        "json-stringify-safe": {
+          "version": "5.0.1",
+          "from": "json-stringify-safe@~5.0.1",
+          "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
+        },
+        "mime-types": {
+          "version": "2.1.11",
+          "from": "mime-types@~2.1.7",
+          "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+          "dependencies": {
+            "mime-db": {
+              "version": "1.23.0",
+              "from": "mime-db@~1.23.0",
+              "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+            }
+          }
+        },
+        "node-uuid": {
+          "version": "1.4.7",
+          "from": "node-uuid@~1.4.7",
+          "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.7.tgz"
+        },
+        "oauth-sign": {
+          "version": "0.8.2",
+          "from": "oauth-sign@~0.8.1",
+          "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz"
+        },
+        "qs": {
+          "version": "6.1.0",
+          "from": "qs@~6.1.0",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.1.0.tgz"
+        },
+        "stringstream": {
+          "version": "0.0.5",
+          "from": "stringstream@~0.0.4",
+          "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz"
+        },
+        "tough-cookie": {
+          "version": "2.2.2",
+          "from": "tough-cookie@~2.2.0",
+          "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.2.2.tgz"
+        },
+        "tunnel-agent": {
+          "version": "0.4.3",
+          "from": "tunnel-agent@~0.4.1",
+          "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz"
+        }
+      }
+    },
+    "requests": {
+      "version": "0.1.7",
+      "from": "requests@^0.1.7",
+      "resolved": "https://registry.npmjs.org/requests/-/requests-0.1.7.tgz",
+      "dependencies": {
+        "axo": {
+          "version": "0.0.1",
+          "from": "axo@0.0.x",
+          "resolved": "https://registry.npmjs.org/axo/-/axo-0.0.1.tgz"
+        },
+        "eventemitter3": {
+          "version": "1.1.1",
+          "from": "eventemitter3@1.1.x",
+          "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.1.1.tgz"
+        },
+        "extendible": {
+          "version": "0.1.1",
+          "from": "extendible@0.1.x",
+          "resolved": "https://registry.npmjs.org/extendible/-/extendible-0.1.1.tgz"
+        },
+        "hang": {
+          "version": "1.0.0",
+          "from": "hang@1.0.x",
+          "resolved": "https://registry.npmjs.org/hang/-/hang-1.0.0.tgz"
+        },
+        "loads": {
+          "version": "0.0.4",
+          "from": "loads@0.0.x",
+          "resolved": "https://registry.npmjs.org/loads/-/loads-0.0.4.tgz",
+          "dependencies": {
+            "failure": {
+              "version": "1.1.1",
+              "from": "failure@1.1.x",
+              "resolved": "https://registry.npmjs.org/failure/-/failure-1.1.1.tgz"
+            },
+            "one-time": {
+              "version": "0.0.4",
+              "from": "one-time@0.0.x",
+              "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz"
+            },
+            "xhr-response": {
+              "version": "1.0.1",
+              "from": "xhr-response@1.0.x",
+              "resolved": "https://registry.npmjs.org/xhr-response/-/xhr-response-1.0.1.tgz"
+            },
+            "xhr-status": {
+              "version": "1.0.0",
+              "from": "xhr-status@1.0.x",
+              "resolved": "https://registry.npmjs.org/xhr-status/-/xhr-status-1.0.0.tgz"
+            }
+          }
+        },
+        "xhr-send": {
+          "version": "1.0.0",
+          "from": "xhr-send@1.0.x",
+          "resolved": "https://registry.npmjs.org/xhr-send/-/xhr-send-1.0.0.tgz"
+        }
+      }
+    },
+    "rimraf": {
+      "version": "2.2.6",
+      "from": "rimraf@2.2.6",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.6.tgz"
+    },
+    "sanitizer": {
+      "version": "0.1.1",
+      "from": "sanitizer@0.1.1",
+      "resolved": "https://registry.npmjs.org/sanitizer/-/sanitizer-0.1.1.tgz"
+    },
+    "settings-sharelatex": {
+      "version": "1.0.0",
+      "from": "settings-sharelatex@git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
+      "resolved": "git+https://github.com/sharelatex/settings-sharelatex.git#cbc5e41c1dbe6789721a14b3fdae05bf22546559",
+      "dependencies": {
+        "coffee-script": {
+          "version": "1.6.0",
+          "from": "coffee-script@1.6.0",
+          "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.0.tgz"
+        }
+      }
+    },
+    "sixpack-client": {
+      "version": "1.0.0",
+      "from": "sixpack-client@^1.0.0",
+      "resolved": "https://registry.npmjs.org/sixpack-client/-/sixpack-client-1.0.0.tgz"
+    },
+    "temp": {
+      "version": "0.8.3",
+      "from": "temp@^0.8.3",
+      "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz",
+      "dependencies": {
+        "os-tmpdir": {
+          "version": "1.0.1",
+          "from": "os-tmpdir@^1.0.0",
+          "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.1.tgz"
+        }
+      }
+    },
+    "underscore": {
+      "version": "1.6.0",
+      "from": "underscore@1.6.0",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz"
+    },
+    "v8-profiler": {
+      "version": "5.6.5",
+      "from": "v8-profiler@^5.2.3",
+      "resolved": "https://registry.npmjs.org/v8-profiler/-/v8-profiler-5.6.5.tgz",
+      "dependencies": {
+        "nan": {
+          "version": "2.3.5",
+          "from": "nan@^2.3.2",
+          "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+        },
+        "node-pre-gyp": {
+          "version": "0.6.28",
+          "from": "node-pre-gyp@^0.6.5",
+          "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.28.tgz",
+          "dependencies": {
+            "mkdirp": {
+              "version": "0.5.1",
+              "from": "mkdirp@~0.5.0",
+              "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+              "dependencies": {
+                "minimist": {
+                  "version": "0.0.8",
+                  "from": "minimist@0.0.8",
+                  "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+                }
+              }
+            },
+            "nopt": {
+              "version": "3.0.6",
+              "from": "nopt@~3.0.1",
+              "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
+              "dependencies": {
+                "abbrev": {
+                  "version": "1.0.7",
+                  "from": "abbrev@1",
+                  "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.7.tgz"
+                }
+              }
+            },
+            "npmlog": {
+              "version": "2.0.4",
+              "from": "npmlog@~2.0.0",
+              "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-2.0.4.tgz",
+              "dependencies": {
+                "ansi": {
+                  "version": "0.3.1",
+                  "from": "ansi@~0.3.1",
+                  "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz"
+                },
+                "are-we-there-yet": {
+                  "version": "1.1.2",
+                  "from": "are-we-there-yet@~1.1.2",
+                  "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.2.tgz",
+                  "dependencies": {
+                    "delegates": {
+                      "version": "1.0.0",
+                      "from": "delegates@^1.0.0",
+                      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz"
+                    },
+                    "readable-stream": {
+                      "version": "2.1.4",
+                      "from": "readable-stream@^2.0.0 || ^1.1.13",
+                      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.4.tgz",
+                      "dependencies": {
+                        "buffer-shims": {
+                          "version": "1.0.0",
+                          "from": "buffer-shims@^1.0.0",
+                          "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
+                        },
+                        "core-util-is": {
+                          "version": "1.0.2",
+                          "from": "core-util-is@~1.0.0",
+                          "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+                        },
+                        "inherits": {
+                          "version": "2.0.1",
+                          "from": "inherits@~2.0.1",
+                          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                        },
+                        "isarray": {
+                          "version": "1.0.0",
+                          "from": "isarray@~1.0.0",
+                          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+                        },
+                        "process-nextick-args": {
+                          "version": "1.0.7",
+                          "from": "process-nextick-args@~1.0.6",
+                          "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+                        },
+                        "string_decoder": {
+                          "version": "0.10.31",
+                          "from": "string_decoder@~0.10.x",
+                          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+                        },
+                        "util-deprecate": {
+                          "version": "1.0.2",
+                          "from": "util-deprecate@~1.0.1",
+                          "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+                        }
+                      }
+                    }
+                  }
+                },
+                "gauge": {
+                  "version": "1.2.7",
+                  "from": "gauge@~1.2.5",
+                  "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz",
+                  "dependencies": {
+                    "has-unicode": {
+                      "version": "2.0.0",
+                      "from": "has-unicode@^2.0.0",
+                      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.0.tgz"
+                    },
+                    "lodash.pad": {
+                      "version": "4.4.0",
+                      "from": "lodash.pad@^4.1.0",
+                      "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.4.0.tgz",
+                      "dependencies": {
+                        "lodash._baseslice": {
+                          "version": "4.0.0",
+                          "from": "lodash._baseslice@~4.0.0",
+                          "resolved": "https://registry.npmjs.org/lodash._baseslice/-/lodash._baseslice-4.0.0.tgz"
+                        },
+                        "lodash._basetostring": {
+                          "version": "4.12.0",
+                          "from": "lodash._basetostring@~4.12.0",
+                          "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
+                        },
+                        "lodash.tostring": {
+                          "version": "4.1.3",
+                          "from": "lodash.tostring@^4.0.0",
+                          "resolved": "https://registry.npmjs.org/lodash.tostring/-/lodash.tostring-4.1.3.tgz"
+                        }
+                      }
+                    },
+                    "lodash.padend": {
+                      "version": "4.5.0",
+                      "from": "lodash.padend@^4.1.0",
+                      "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.5.0.tgz",
+                      "dependencies": {
+                        "lodash._baseslice": {
+                          "version": "4.0.0",
+                          "from": "lodash._baseslice@~4.0.0",
+                          "resolved": "https://registry.npmjs.org/lodash._baseslice/-/lodash._baseslice-4.0.0.tgz"
+                        },
+                        "lodash._basetostring": {
+                          "version": "4.12.0",
+                          "from": "lodash._basetostring@~4.12.0",
+                          "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
+                        },
+                        "lodash.tostring": {
+                          "version": "4.1.3",
+                          "from": "lodash.tostring@^4.0.0",
+                          "resolved": "https://registry.npmjs.org/lodash.tostring/-/lodash.tostring-4.1.3.tgz"
+                        }
+                      }
+                    },
+                    "lodash.padstart": {
+                      "version": "4.5.0",
+                      "from": "lodash.padstart@^4.1.0",
+                      "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.5.0.tgz",
+                      "dependencies": {
+                        "lodash._baseslice": {
+                          "version": "4.0.0",
+                          "from": "lodash._baseslice@~4.0.0",
+                          "resolved": "https://registry.npmjs.org/lodash._baseslice/-/lodash._baseslice-4.0.0.tgz"
+                        },
+                        "lodash._basetostring": {
+                          "version": "4.12.0",
+                          "from": "lodash._basetostring@~4.12.0",
+                          "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
+                        },
+                        "lodash.tostring": {
+                          "version": "4.1.3",
+                          "from": "lodash.tostring@^4.0.0",
+                          "resolved": "https://registry.npmjs.org/lodash.tostring/-/lodash.tostring-4.1.3.tgz"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            },
+            "rc": {
+              "version": "1.1.6",
+              "from": "rc@~1.1.0",
+              "resolved": "https://registry.npmjs.org/rc/-/rc-1.1.6.tgz",
+              "dependencies": {
+                "deep-extend": {
+                  "version": "0.4.1",
+                  "from": "deep-extend@~0.4.0",
+                  "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.1.tgz"
+                },
+                "ini": {
+                  "version": "1.3.4",
+                  "from": "ini@~1.3.0",
+                  "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz"
+                },
+                "minimist": {
+                  "version": "1.2.0",
+                  "from": "minimist@^1.2.0",
+                  "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz"
+                },
+                "strip-json-comments": {
+                  "version": "1.0.4",
+                  "from": "strip-json-comments@~1.0.4",
+                  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz"
+                }
+              }
+            },
+            "rimraf": {
+              "version": "2.5.2",
+              "from": "rimraf@~2.5.0",
+              "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.2.tgz",
+              "dependencies": {
+                "glob": {
+                  "version": "7.0.3",
+                  "from": "glob@^7.0.0",
+                  "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.3.tgz",
+                  "dependencies": {
+                    "inflight": {
+                      "version": "1.0.5",
+                      "from": "inflight@^1.0.4",
+                      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+                      "dependencies": {
+                        "wrappy": {
+                          "version": "1.0.2",
+                          "from": "wrappy@1",
+                          "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                        }
+                      }
+                    },
+                    "inherits": {
+                      "version": "2.0.1",
+                      "from": "inherits@2",
+                      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                    },
+                    "minimatch": {
+                      "version": "3.0.0",
+                      "from": "minimatch@2 || 3",
+                      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+                      "dependencies": {
+                        "brace-expansion": {
+                          "version": "1.1.4",
+                          "from": "brace-expansion@^1.0.0",
+                          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+                          "dependencies": {
+                            "balanced-match": {
+                              "version": "0.4.1",
+                              "from": "balanced-match@^0.4.1",
+                              "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+                            },
+                            "concat-map": {
+                              "version": "0.0.1",
+                              "from": "concat-map@0.0.1",
+                              "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+                            }
+                          }
+                        }
+                      }
+                    },
+                    "once": {
+                      "version": "1.3.3",
+                      "from": "once@^1.3.0",
+                      "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+                      "dependencies": {
+                        "wrappy": {
+                          "version": "1.0.2",
+                          "from": "wrappy@1",
+                          "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                        }
+                      }
+                    },
+                    "path-is-absolute": {
+                      "version": "1.0.0",
+                      "from": "path-is-absolute@^1.0.0",
+                      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+                    }
+                  }
+                }
+              }
+            },
+            "semver": {
+              "version": "5.1.0",
+              "from": "semver@~5.1.0",
+              "resolved": "https://registry.npmjs.org/semver/-/semver-5.1.0.tgz"
+            },
+            "tar": {
+              "version": "2.2.1",
+              "from": "tar@~2.2.0",
+              "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
+              "dependencies": {
+                "block-stream": {
+                  "version": "0.0.9",
+                  "from": "block-stream@*",
+                  "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz"
+                },
+                "fstream": {
+                  "version": "1.0.9",
+                  "from": "fstream@^1.0.2",
+                  "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.9.tgz",
+                  "dependencies": {
+                    "graceful-fs": {
+                      "version": "4.1.4",
+                      "from": "graceful-fs@^4.1.2",
+                      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.4.tgz"
+                    }
+                  }
+                },
+                "inherits": {
+                  "version": "2.0.1",
+                  "from": "inherits@~2.0.1",
+                  "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                }
+              }
+            },
+            "tar-pack": {
+              "version": "3.1.3",
+              "from": "tar-pack@~3.1.0",
+              "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.1.3.tgz",
+              "dependencies": {
+                "debug": {
+                  "version": "2.2.0",
+                  "from": "debug@~2.2.0",
+                  "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+                  "dependencies": {
+                    "ms": {
+                      "version": "0.7.1",
+                      "from": "ms@0.7.1",
+                      "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+                    }
+                  }
+                },
+                "fstream": {
+                  "version": "1.0.9",
+                  "from": "fstream@~1.0.8",
+                  "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.9.tgz",
+                  "dependencies": {
+                    "graceful-fs": {
+                      "version": "4.1.4",
+                      "from": "graceful-fs@^4.1.2",
+                      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.4.tgz"
+                    },
+                    "inherits": {
+                      "version": "2.0.1",
+                      "from": "inherits@~2.0.0",
+                      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                    }
+                  }
+                },
+                "fstream-ignore": {
+                  "version": "1.0.5",
+                  "from": "fstream-ignore@~1.0.3",
+                  "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz",
+                  "dependencies": {
+                    "inherits": {
+                      "version": "2.0.1",
+                      "from": "inherits@2",
+                      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                    },
+                    "minimatch": {
+                      "version": "3.0.0",
+                      "from": "minimatch@^3.0.0",
+                      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+                      "dependencies": {
+                        "brace-expansion": {
+                          "version": "1.1.4",
+                          "from": "brace-expansion@^1.0.0",
+                          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+                          "dependencies": {
+                            "balanced-match": {
+                              "version": "0.4.1",
+                              "from": "balanced-match@^0.4.1",
+                              "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+                            },
+                            "concat-map": {
+                              "version": "0.0.1",
+                              "from": "concat-map@0.0.1",
+                              "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+                            }
+                          }
+                        }
+                      }
+                    }
+                  }
+                },
+                "once": {
+                  "version": "1.3.3",
+                  "from": "once@~1.3.3",
+                  "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+                  "dependencies": {
+                    "wrappy": {
+                      "version": "1.0.2",
+                      "from": "wrappy@1",
+                      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+                    }
+                  }
+                },
+                "readable-stream": {
+                  "version": "2.0.6",
+                  "from": "readable-stream@~2.0.4",
+                  "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz",
+                  "dependencies": {
+                    "core-util-is": {
+                      "version": "1.0.2",
+                      "from": "core-util-is@~1.0.0",
+                      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+                    },
+                    "inherits": {
+                      "version": "2.0.1",
+                      "from": "inherits@~2.0.1",
+                      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+                    },
+                    "isarray": {
+                      "version": "1.0.0",
+                      "from": "isarray@~1.0.0",
+                      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+                    },
+                    "process-nextick-args": {
+                      "version": "1.0.7",
+                      "from": "process-nextick-args@~1.0.6",
+                      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+                    },
+                    "string_decoder": {
+                      "version": "0.10.31",
+                      "from": "string_decoder@~0.10.x",
+                      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+                    },
+                    "util-deprecate": {
+                      "version": "1.0.2",
+                      "from": "util-deprecate@~1.0.1",
+                      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+                    }
+                  }
+                },
+                "uid-number": {
+                  "version": "0.0.6",
+                  "from": "uid-number@~0.0.6",
+                  "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "xml2js": {
+      "version": "0.2.0",
+      "from": "xml2js@0.2.0",
+      "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.0.tgz",
+      "dependencies": {
+        "sax": {
+          "version": "1.2.1",
+          "from": "sax@>=0.1.1",
+          "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz"
+        }
+      }
+    }
+  }
+}

+ 12 - 7
services/web/package.json

@@ -18,6 +18,7 @@
     "body-parser": "^1.13.1",
     "body-parser": "^1.13.1",
     "bufferedstream": "1.6.0",
     "bufferedstream": "1.6.0",
     "connect-redis": "2.3.0",
     "connect-redis": "2.3.0",
+    "contentful": "^3.3.14",
     "cookie": "^0.2.3",
     "cookie": "^0.2.3",
     "cookie-parser": "1.3.5",
     "cookie-parser": "1.3.5",
     "csurf": "^1.8.3",
     "csurf": "^1.8.3",
@@ -30,8 +31,9 @@
     "jade": "~1.3.1",
     "jade": "~1.3.1",
     "ldapjs": "^0.7.1",
     "ldapjs": "^0.7.1",
     "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
     "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
+    "lodash": "^4.13.1",
     "lynx": "0.1.1",
     "lynx": "0.1.1",
-    "marked": "^0.3.3",
+    "marked": "^0.3.5",
     "method-override": "^2.3.3",
     "method-override": "^2.3.3",
     "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
     "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
     "mimelib": "0.2.14",
     "mimelib": "0.2.14",
@@ -54,32 +56,35 @@
     "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
     "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
     "sixpack-client": "^1.0.0",
     "sixpack-client": "^1.0.0",
     "temp": "^0.8.3",
     "temp": "^0.8.3",
-    "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
     "underscore": "1.6.0",
     "underscore": "1.6.0",
-    "underscore.string": "^3.0.2",
     "v8-profiler": "^5.2.3",
     "v8-profiler": "^5.2.3",
     "xml2js": "0.2.0"
     "xml2js": "0.2.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "bunyan": "0.22.1",
     "bunyan": "0.22.1",
+    "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
     "chai": "",
     "chai": "",
     "chai-spies": "",
     "chai-spies": "",
     "grunt": "0.4.1",
     "grunt": "0.4.1",
     "grunt-available-tasks": "0.4.1",
     "grunt-available-tasks": "0.4.1",
     "grunt-bunyan": "0.5.0",
     "grunt-bunyan": "0.5.0",
-    "grunt-concurrent": "0.4.3",
     "grunt-contrib-clean": "0.5.0",
     "grunt-contrib-clean": "0.5.0",
     "grunt-contrib-coffee": "0.10.0",
     "grunt-contrib-coffee": "0.10.0",
     "grunt-contrib-less": "0.9.0",
     "grunt-contrib-less": "0.9.0",
     "grunt-contrib-requirejs": "0.4.1",
     "grunt-contrib-requirejs": "0.4.1",
-    "grunt-execute": "0.1.5",
+    "grunt-contrib-watch": "^1.0.0",
+    "grunt-env": "0.4.4",
+    "clean-css": "^3.4.18",
+    "grunt-exec": "^0.4.7",
+    "grunt-execute": "^0.2.2",
     "grunt-file-append": "0.0.6",
     "grunt-file-append": "0.0.6",
     "grunt-git-rev-parse": "^0.1.4",
     "grunt-git-rev-parse": "^0.1.4",
     "grunt-mocha-test": "0.9.0",
     "grunt-mocha-test": "0.9.0",
+    "grunt-newer": "^1.2.0",
+    "grunt-parallel": "^0.5.1",
     "grunt-sed": "^0.1.1",
     "grunt-sed": "^0.1.1",
     "sandboxed-module": "0.2.0",
     "sandboxed-module": "0.2.0",
     "sinon": "",
     "sinon": "",
-    "timekeeper": "",
-    "grunt-env": "0.4.4"
+    "timekeeper": ""
   }
   }
 }
 }

+ 12 - 1
services/web/public/coffee/ide.coffee

@@ -42,7 +42,7 @@ define [
 	ReferencesManager
 	ReferencesManager
 ) ->
 ) ->
 
 
-	App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
+	App.controller "IdeController", ($scope, $timeout, ide, localStorage, event_tracking) ->
 		# Don't freak out if we're already in an apply callback
 		# Don't freak out if we're already in an apply callback
 		$scope.$originalApply = $scope.$apply
 		$scope.$originalApply = $scope.$apply
 		$scope.$apply = (fn = () ->) ->
 		$scope.$apply = (fn = () ->) ->
@@ -69,6 +69,17 @@ define [
 
 
 		$scope.chat = {}
 		$scope.chat = {}
 
 
+		# Tracking code.
+		$scope.$watch "ui.view", (newView, oldView) ->
+			if newView? and newView != "editor" and newView != "pdf"
+				event_tracking.sendCountlyOnce "ide-open-view-#{ newView }-once" 
+
+		$scope.$watch "ui.chatOpen", (isOpen) ->
+			event_tracking.sendCountlyOnce "ide-open-chat-once" if isOpen
+
+		$scope.$watch "ui.leftMenuShown", (isOpen) ->
+			event_tracking.sendCountlyOnce "ide-open-left-menu-once" if isOpen
+		# End of tracking code.
 
 
 		window._ide = ide
 		window._ide = ide
 
 

+ 4 - 2
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -74,15 +74,17 @@ define [
 				editor.commands.removeCommand "foldall"
 				editor.commands.removeCommand "foldall"
 				
 				
 				# For European keyboards, the / is above 7 so needs Shift pressing.
 				# For European keyboards, the / is above 7 so needs Shift pressing.
-				# This comes through as Ctrl-Shift-/ which is mapped to toggleBlockComment.
+				# This comes through as Command-Shift-/ on OS X, which is mapped to 
+				# toggleBlockComment.
 				# This doesn't do anything for LaTeX, so remap this to togglecomment to
 				# This doesn't do anything for LaTeX, so remap this to togglecomment to
 				# work for European keyboards as normal.
 				# work for European keyboards as normal.
+				# On Windows, the key combo comes as Ctrl-Shift-7.
 				editor.commands.removeCommand "toggleBlockComment"
 				editor.commands.removeCommand "toggleBlockComment"
 				editor.commands.removeCommand "togglecomment"
 				editor.commands.removeCommand "togglecomment"
 				
 				
 				editor.commands.addCommand {
 				editor.commands.addCommand {
 					name: "togglecomment",
 					name: "togglecomment",
-					bindKey: { win: "Ctrl-/|Ctrl-Shift-/", mac: "Command-/|Command-Shift-/" },
+					bindKey: { win: "Ctrl-/|Ctrl-Shift-7", mac: "Command-/|Command-Shift-/" },
 					exec: (editor) -> editor.toggleCommentLines(),
 					exec: (editor) -> editor.toggleCommentLines(),
 					multiSelectAction: "forEachLine",
 					multiSelectAction: "forEachLine",
 					scrollIntoView: "selectionPart"
 					scrollIntoView: "selectionPart"

+ 2 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee

@@ -134,6 +134,8 @@ define [
 						editor.completer.showPopup(editor)
 						editor.completer.showPopup(editor)
 						editor.completer.cancelContextMenu()
 						editor.completer.cancelContextMenu()
 						$(editor.completer.popup?.container).css({'font-size': @$scope.fontSize + 'px'})
 						$(editor.completer.popup?.container).css({'font-size': @$scope.fontSize + 'px'})
+						if editor.completer?.completions?.filtered?.length == 0
+							editor.completer.detach()
 					bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
 					bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
 				}
 				}
 
 

+ 3 - 1
services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee

@@ -2,8 +2,10 @@ define [
 	"base"
 	"base"
 	"ace/ace"
 	"ace/ace"
 ], (App) ->
 ], (App) ->
-	App.controller "HotkeysController", ($scope, $modal) ->
+	App.controller "HotkeysController", ($scope, $modal, event_tracking) ->
 		$scope.openHotkeysModal = ->
 		$scope.openHotkeysModal = ->
+			event_tracking.sendCountly "ide-open-hotkeys-modal"
+
 			$modal.open {
 			$modal.open {
 				templateUrl: "hotkeysModalTemplate"
 				templateUrl: "hotkeysModalTemplate"
 				controller:  "HotkeysModalController"
 				controller:  "HotkeysModalController"

+ 20 - 0
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee

@@ -0,0 +1,20 @@
+define [
+	"libs/latex-log-parser"
+	"ide/human-readable-logs/HumanReadableLogsRules"
+], (LogParser, ruleset) ->
+	parse : (rawLog, options) ->
+		parsedLogEntries = LogParser.parse(rawLog, options)
+
+		_getRule = (logMessage) ->
+			return rule for rule in ruleset when rule.regexToMatch.test logMessage
+
+		for entry in parsedLogEntries.all
+			ruleDetails = _getRule entry.message
+
+			if (ruleDetails?)
+				entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1) if ruleDetails.regexToMatch?
+				
+				entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint?
+				entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL?
+	
+		return parsedLogEntries

+ 91 - 0
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee

@@ -0,0 +1,91 @@
+define -> [
+		regexToMatch: /Misplaced alignment tab character \&/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Misplaced_alignment_tab_character_%26"
+		humanReadableHint: """
+			You have placed an alignment tab character '&' in the wrong place. If you want to align something, you must write it inside an align environment such as \\begin{align} \u2026 \\end{align}, \\begin{tabular} \u2026 \\end{tabular}, etc. If you want to write an ampersand '&' in text, you must write \\& instead.
+		"""
+	,
+		regexToMatch: /Extra alignment tab has been changed to \\cr/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr"
+		humanReadableHint: """
+			You have written too many alignment tabs in a table, causing one of them to be turned into a line break. Make sure you have specified the correct number of columns in your <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Tables\">table</a>.
+		"""
+	,
+		regexToMatch: /Display math should end with \$\$/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Display_math_should_end_with_$$"
+		humanReadableHint: """
+			You have forgotten a $ sign at the end of 'display math' mode. When writing in display math mode, you must always math write inside $$ \u2026 $$. Check that the number of $s match around each math expression.
+		"""
+	,
+		regexToMatch: /Missing [{$] inserted./
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Missing_$_inserted"
+		humanReadableHint: """
+			Check that your $'s match around math expressions. If they do, then you've probably used a symbol in normal text that needs to be in math mode. Symbols such as subscripts ( _ ), integrals ( \\int ), Greek letters ( \\alpha, \\beta, \\delta ), and modifiers (\\vec{x}, \\tilde{x} ) must be written in math mode. See the full list <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/Missing_$_inserted \">here</a>.If you intended to use mathematics mode, then use $ \u2026 $ for 'inline math mode', $$ \u2026 $$ for 'display math mode' or alternatively \begin{math} \u2026 \end{math}.
+		"""
+	,
+		regexToMatch: /(undefined )?[rR]eference(s)?.+(undefined)?/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_undefined_references"
+		humanReadableHint: """
+			You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.
+		"""
+	,
+		regexToMatch: /Citation .+ on page .+ undefined on input line .+/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX"
+		humanReadableHint: """
+			You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.
+		"""
+	,
+		regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_multiply-defined_labels"
+		humanReadableHint: """
+			You have used the same label more than once. Check that each \\label{...} labels only one item.
+		"""
+	,
+		regexToMatch: /`!?h' float specifier changed to `!?ht'/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27"
+		humanReadableHint: """
+			The float specifier 'h' is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using 'ht', or even 'htbp' if necessary. If you want to try keep the float here anyway, check out the <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">float package</a>.
+		"""
+	,
+		regexToMatch: /No positions in optional float specifier/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier"
+		humanReadableHint: """
+			You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \begin{figure}[h]), or remove the square brackets (e.g. \begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
+		"""
+	,
+		regexToMatch: /Undefined control sequence/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Undefined_control_sequence"
+		humanReadableHint: """
+			The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.
+		"""
+	,
+		regexToMatch: /File .+ not found/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX"
+		humanReadableHint: """
+			The compiler cannot find the file you want to include. Make sure that you have <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Including_images_in_ShareLaTeX\">uploaded the file</a> and <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.\">specified the file location correctly</a>.
+		"""
+	,
+		regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX"
+		humanReadableHint: """
+			The compiler does not recognise the file type of one of your images. Make sure you are using a <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.\">supported image format</a> for your choice of compiler, and check that there are no periods (.) in the name of your image.
+		"""
+	,
+		regexToMatch: /LaTeX Error: Unknown float option `H'/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27"
+		humanReadableHint: """
+			The compiler isn't recognizing the float option 'H'. Include \\usepackage{float} in your preamble to fix this.
+		"""
+	,
+		regexToMatch: /LaTeX Error: Unknown float option `q'/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27"
+		humanReadableHint: """
+			You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
+		"""
+	,
+		regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode"
+		humanReadableHint: """
+			You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ \u2026 $ or \\begin{math} \u2026 \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.
+		"""
+]

+ 150 - 38
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -1,15 +1,67 @@
 define [
 define [
 	"base"
 	"base"
-	"libs/latex-log-parser"
+	"ace/ace"
+	"ide/human-readable-logs/HumanReadableLogs"
 	"libs/bib-log-parser"
 	"libs/bib-log-parser"
-], (App, LogParser, BibLogParser) ->
-	App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) ->
+	"services/log-hints-feedback"
+], (App, Ace, HumanReadableLogs, BibLogParser) ->
+	App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, logHintsFeedback, localStorage) ->
 
 
+		# enable per-user containers by default
+		perUserCompile = true
 		autoCompile = true
 		autoCompile = true
 
 
 		# pdf.view = uncompiled | pdf | errors
 		# pdf.view = uncompiled | pdf | errors
 		$scope.pdf.view = if $scope?.pdf?.url then 'pdf' else 'uncompiled'
 		$scope.pdf.view = if $scope?.pdf?.url then 'pdf' else 'uncompiled'
 		$scope.shouldShowLogs = false
 		$scope.shouldShowLogs = false
+		$scope.wikiEnabled = window.wikiEnabled;
+
+		# view logic to check whether the files dropdown should "drop up" or "drop down"
+		$scope.shouldDropUp = false
+
+		logsContainerEl	= document.querySelector ".pdf-logs"
+		filesDropdownEl	= logsContainerEl?.querySelector ".files-dropdown"
+
+		# get the top coordinate of the files dropdown as a ratio (to the logs container height)
+		# logs container supports scrollable content, so it's possible that ratio > 1.
+		getFilesDropdownTopCoordAsRatio = () ->
+			 filesDropdownEl?.getBoundingClientRect().top / logsContainerEl?.getBoundingClientRect().height
+
+		$scope.$watch "shouldShowLogs", (shouldShow) ->
+			if shouldShow
+				$scope.$applyAsync () -> 
+					$scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
+
+		# log hints tracking
+		$scope.logHintsNegFeedbackValues = logHintsFeedback.feedbackOpts
+		
+		$scope.trackLogHintsLearnMore = () ->
+			event_tracking.sendCountly "logs-hints-learn-more"
+
+		trackLogHintsFeedback = (isPositive, hintId) ->
+			event_tracking.send "log-hints", (if isPositive then "feedback-positive" else "feedback-negative"), hintId
+			event_tracking.sendCountly (if isPositive then "log-hints-feedback-positive" else "log-hints-feedback-negative"), { hintId }
+
+		$scope.trackLogHintsNegFeedbackDetails = (hintId, feedbackOpt, feedbackOtherVal) ->
+			logHintsFeedback.submitFeedback hintId, feedbackOpt, feedbackOtherVal
+
+		$scope.trackLogHintsPositiveFeedback = (hintId) -> trackLogHintsFeedback true, hintId
+		$scope.trackLogHintsNegativeFeedback = (hintId) -> trackLogHintsFeedback false, hintId
+
+		if ace.require("ace/lib/useragent").isMac
+			$scope.modifierKey = "Cmd"
+		else
+			$scope.modifierKey = "Ctrl"
+
+		# utility for making a query string from a hash, could use jquery $.param
+		createQueryString = (args) ->
+			qs_args = ("#{k}=#{v}" for k, v of args)
+			if qs_args.length then "?" + qs_args.join("&") else ""
+
+		$scope.stripHTMLFromString = (htmlStr) ->
+   			tmp = document.createElement("DIV")
+   			tmp.innerHTML = htmlStr
+   			return tmp.textContent || tmp.innerText || ""
 
 
 		$scope.$on "project:joined", () ->
 		$scope.$on "project:joined", () ->
 			return if !autoCompile
 			return if !autoCompile
@@ -28,13 +80,14 @@ define [
 
 
 		sendCompileRequest = (options = {}) ->
 		sendCompileRequest = (options = {}) ->
 			url = "/project/#{$scope.project_id}/compile"
 			url = "/project/#{$scope.project_id}/compile"
+			params = {}
 			if options.isAutoCompile
 			if options.isAutoCompile
-				url += "?auto_compile=true"
+				params["auto_compile"]=true
 			return $http.post url, {
 			return $http.post url, {
 				rootDoc_id: options.rootDocOverride_id or null
 				rootDoc_id: options.rootDocOverride_id or null
 				draft: $scope.draft
 				draft: $scope.draft
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
-			}
+			}, {params: params}
 
 
 		parseCompileResponse = (response) ->		
 		parseCompileResponse = (response) ->		
 
 
@@ -42,20 +95,36 @@ define [
 			$scope.pdf.error      = false
 			$scope.pdf.error      = false
 			$scope.pdf.timedout   = false
 			$scope.pdf.timedout   = false
 			$scope.pdf.failure    = false
 			$scope.pdf.failure    = false
-			$scope.pdf.projectTooLarge = false
 			$scope.pdf.url        = null
 			$scope.pdf.url        = null
 			$scope.pdf.clsiMaintenance = false
 			$scope.pdf.clsiMaintenance = false
 			$scope.pdf.tooRecentlyCompiled = false
 			$scope.pdf.tooRecentlyCompiled = false
 			$scope.pdf.renderingError = false
 			$scope.pdf.renderingError = false
+			$scope.pdf.projectTooLarge = false
+			$scope.pdf.compileTerminated = false
 
 
 			# make a cache to look up files by name
 			# make a cache to look up files by name
 			fileByPath = {}
 			fileByPath = {}
-			for file in response.outputFiles
-				fileByPath[file.path] = file
+			if response?.outputFiles?
+				for file in response?.outputFiles
+					fileByPath[file.path] = file
+
+			# prepare query string
+			qs = {}
+			# add a query string parameter for the compile group
+			if response.compileGroup?
+				ide.compileGroup = qs.compileGroup = response.compileGroup
+			# add a query string parameter for the clsi server id
+			if response.clsiServerId?
+				ide.clsiServerId = qs.clsiserverid = response.clsiServerId
 
 
 			if response.status == "timedout"
 			if response.status == "timedout"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.timedout = true
 				$scope.pdf.timedout = true
+				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
+			else if response.status == "terminated"
+				$scope.pdf.view = 'errors'
+				$scope.pdf.compileTerminated = true
+				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
 			else if response.status == "autocompile-backoff"
 			else if response.status == "autocompile-backoff"
 				$scope.pdf.view = 'uncompiled'
 				$scope.pdf.view = 'uncompiled'
 			else if response.status == "project-too-large"
 			else if response.status == "project-too-large"
@@ -72,12 +141,13 @@ define [
 			else if response.status == "too-recently-compiled"
 			else if response.status == "too-recently-compiled"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.tooRecentlyCompiled = true
 				$scope.pdf.tooRecentlyCompiled = true
+			else if response.status == "validation-problems"
+				$scope.pdf.view = "validation-problems"
+				$scope.pdf.validation = response.validationProblems
 			else if response.status == "success"
 			else if response.status == "success"
 				$scope.pdf.view = 'pdf'
 				$scope.pdf.view = 'pdf'
 				$scope.shouldShowLogs = false
 				$scope.shouldShowLogs = false
 
 
-				# prepare query string
-				qs = {}
 				# define the base url. if the pdf file has a build number, pass it to the clsi in the url
 				# define the base url. if the pdf file has a build number, pass it to the clsi in the url
 				if fileByPath['output.pdf']?.url?
 				if fileByPath['output.pdf']?.url?
 					$scope.pdf.url = fileByPath['output.pdf'].url
 					$scope.pdf.url = fileByPath['output.pdf'].url
@@ -89,17 +159,11 @@ define [
 				# check if we need to bust cache (build id is unique so don't need it in that case)
 				# check if we need to bust cache (build id is unique so don't need it in that case)
 				if not fileByPath['output.pdf']?.build?
 				if not fileByPath['output.pdf']?.build?
 					qs.cache_bust = "#{Date.now()}"
 					qs.cache_bust = "#{Date.now()}"
-				# add a query string parameter for the compile group
-				if response.compileGroup?
-					$scope.pdf.compileGroup = response.compileGroup
-					qs.compileGroup = "#{$scope.pdf.compileGroup}"
-				if response.clsiServerId?
-					qs.clsiserverid = response.clsiServerId
-					ide.clsiServerId = response.clsiServerId
 				# convert the qs hash into a query string and append it
 				# convert the qs hash into a query string and append it
-				qs_args = ("#{k}=#{v}" for k, v of qs)
-				$scope.pdf.qs = if qs_args.length then "?" + qs_args.join("&") else ""
-				$scope.pdf.url += $scope.pdf.qs
+				$scope.pdf.url += createQueryString qs
+				# Save all downloads as files
+				qs.popupDownload = true
+				$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
 
 
 				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
 				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
 
 
@@ -108,17 +172,18 @@ define [
 
 
 			if !response.outputFiles?
 			if !response.outputFiles?
 				return
 				return
+
+			# prepare list of output files for download dropdown
+			qs = {}
+			if response.clsiServerId?
+				qs.clsiserverid = response.clsiServerId
 			for file in response.outputFiles
 			for file in response.outputFiles
 				if IGNORE_FILES.indexOf(file.path) == -1
 				if IGNORE_FILES.indexOf(file.path) == -1
-					# Turn 'output.blg' into 'blg file'.
-					if file.path.match(/^output\./)
-						file.name = "#{file.path.replace(/^output\./, "")} file"
-					else
-						file.name = file.path
-					file.url = "/project/#{project_id}/output/#{file.path}"
-					if response.clsiServerId?
-						file.url = file.url + "?clsiserverid=#{response.clsiServerId}"
-					$scope.pdf.outputFiles.push file
+					$scope.pdf.outputFiles.push {
+						# Turn 'output.blg' into 'blg file'.
+						name: if file.path.match(/^output\./) then "#{file.path.replace(/^output\./, "")} file" else file.path
+						url: "/project/#{project_id}/output/#{file.path}" + createQueryString qs
+					}
 
 
 
 
 		fetchLogs = (logFile, blgFile) ->
 		fetchLogs = (logFile, blgFile) ->
@@ -127,14 +192,17 @@ define [
 				opts =
 				opts =
 					method:"GET"
 					method:"GET"
 					params:
 					params:
-						build:file.build
+						compileGroup:ide.compileGroup
 						clsiserverid:ide.clsiServerId
 						clsiserverid:ide.clsiServerId
-				if file.url?  # FIXME clean this up when we have file.urls out consistently
+				if file?.url?  # FIXME clean this up when we have file.urls out consistently
 					opts.url = file.url
 					opts.url = file.url
 				else if file?.build?
 				else if file?.build?
 					opts.url = "/project/#{$scope.project_id}/build/#{file.build}/output/#{name}"
 					opts.url = "/project/#{$scope.project_id}/build/#{file.build}/output/#{name}"
 				else
 				else
 					opts.url = "/project/#{$scope.project_id}/output/#{name}"
 					opts.url = "/project/#{$scope.project_id}/output/#{name}"
+				# check if we need to bust cache (build id is unique so don't need it in that case)
+				if not file?.build?
+					opts.params.cache_bust = "#{Date.now()}"
 				return $http(opts)
 				return $http(opts)
 
 
 			# accumulate the log entries
 			# accumulate the log entries
@@ -150,7 +218,7 @@ define [
 			# use the parsers for each file type
 			# use the parsers for each file type
 			processLog = (log) ->
 			processLog = (log) ->
 				$scope.pdf.rawLog = log
 				$scope.pdf.rawLog = log
-				{errors, warnings, typesetting} = LogParser.parse(log, ignoreDuplicates: true)
+				{errors, warnings, typesetting} = HumanReadableLogs.parse(log, ignoreDuplicates: true)
 				all = [].concat errors, warnings, typesetting
 				all = [].concat errors, warnings, typesetting
 				accumulateResults {all, errors, warnings}
 				accumulateResults {all, errors, warnings}
 
 
@@ -204,7 +272,7 @@ define [
 			return null
 			return null
 
 
 		normalizeFilePath = (path) ->
 		normalizeFilePath = (path) ->
-			path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}\/(\.\/)?/, "")
+			path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/, "")
 			path = path.replace(/^\/compile\//, "")
 			path = path.replace(/^\/compile\//, "")
 
 
 			rootDocDirname = ide.fileTreeManager.getRootDocDirname()
 			rootDocDirname = ide.fileTreeManager.getRootDocDirname()
@@ -215,6 +283,9 @@ define [
 
 
 		$scope.recompile = (options = {}) ->
 		$scope.recompile = (options = {}) ->
 			return if $scope.pdf.compiling
 			return if $scope.pdf.compiling
+
+			event_tracking.sendCountlySampled "editor-recompile-sampled", options
+
 			$scope.pdf.compiling = true
 			$scope.pdf.compiling = true
 
 
 			ide.$scope.$broadcast("flush-changes")
 			ide.$scope.$broadcast("flush-changes")
@@ -234,6 +305,21 @@ define [
 
 
 		# This needs to be public.
 		# This needs to be public.
 		ide.$scope.recompile = $scope.recompile
 		ide.$scope.recompile = $scope.recompile
+		# This method is a simply wrapper and exists only for tracking purposes.
+		ide.$scope.recompileViaKey = () ->
+			$scope.recompile { keyShortcut: true }
+
+		$scope.stop = () ->
+			return if !$scope.pdf.compiling
+
+			$http {
+				url: "/project/#{$scope.project_id}/compile/stop"
+				method: "POST"
+				params:
+					clsiserverid:ide.clsiServerId
+				headers:
+					"X-Csrf-Token": window.csrfToken
+			}
 
 
 		$scope.clearCache = () ->
 		$scope.clearCache = () ->
 			$http {
 			$http {
@@ -247,6 +333,7 @@ define [
 
 
 		$scope.toggleLogs = () ->
 		$scope.toggleLogs = () ->
 			$scope.shouldShowLogs = !$scope.shouldShowLogs
 			$scope.shouldShowLogs = !$scope.shouldShowLogs
+			event_tracking.sendCountlyOnce "ide-open-logs-once" if $scope.shouldShowLogs
 
 
 		$scope.showPdf = () ->
 		$scope.showPdf = () ->
 			$scope.pdf.view = "pdf"
 			$scope.pdf.view = "pdf"
@@ -254,6 +341,7 @@ define [
 
 
 		$scope.toggleRawLog = () ->
 		$scope.toggleRawLog = () ->
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
+			event_tracking.sendCountly "logs-view-raw" if $scope.pdf.showRawLog
 
 
 		$scope.openClearCacheModal = () ->
 		$scope.openClearCacheModal = () ->
 			modalInstance = $modal.open(
 			modalInstance = $modal.open(
@@ -287,10 +375,16 @@ define [
 
 
 		$scope.startFreeTrial = (source) ->
 		$scope.startFreeTrial = (source) ->
 			ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source)
 			ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source)
+
+			event_tracking.sendCountly "subscription-start-trial", { source }
+
 			window.open("/user/subscription/new?planCode=student_free_trial_7_days")
 			window.open("/user/subscription/new?planCode=student_free_trial_7_days")
 			$scope.startedFreeTrial = true
 			$scope.startedFreeTrial = true
 
 
 	App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
 	App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
+		# enable per-user containers by default
+		perUserCompile = true
+
 		synctex =
 		synctex =
 			syncToPdf: (cursorPosition) ->
 			syncToPdf: (cursorPosition) ->
 				deferred = $q.defer()
 				deferred = $q.defer()
@@ -339,18 +433,35 @@ define [
 					deferred.reject()
 					deferred.reject()
 					return deferred.promise
 					return deferred.promise
 
 
+				# FIXME: this actually works better if it's halfway across the
+				# page (or the visible part of the page). Synctex doesn't
+				# always find the right place in the file when the point is at
+				# the edge of the page, it sometimes returns the start of the
+				# next paragraph instead.
+				h = position.offset.left
+
+				# Compute the vertical position to pass to synctex, which
+				# works with coordinates increasing from the top of the page
+				# down.  This matches the browser's DOM coordinate of the
+				# click point, but the pdf position is measured from the
+				# bottom of the page so we need to invert it.
+				if options.fromPdfPosition and position.pageSize?.height?
+					v = (position.pageSize.height - position.offset.top) or 0 # measure from pdf point (inverted)
+				else
+					v = position.offset.top or 0 # measure from html click position
+
 				# It's not clear exactly where we should sync to if it wasn't directly
 				# It's not clear exactly where we should sync to if it wasn't directly
 				# clicked on, but a little bit down from the very top seems best.
 				# clicked on, but a little bit down from the very top seems best.
 				if options.includeVisualOffset
 				if options.includeVisualOffset
-					position.offset.top = position.offset.top + 80
+					v += 72 # use the same value as in pdfViewer highlighting visual offset
 
 
 				$http({
 				$http({
 						url: "/project/#{ide.project_id}/sync/pdf",
 						url: "/project/#{ide.project_id}/sync/pdf",
 						method: "GET",
 						method: "GET",
 						params: {
 						params: {
 							page: position.page + 1
 							page: position.page + 1
-							h: position.offset.left.toFixed(2)
-							v: position.offset.top.toFixed(2)
+							h: h.toFixed(2)
+							v: v.toFixed(2)
 							clsiserverid:ide.clsiServerId
 							clsiserverid:ide.clsiServerId
 						}
 						}
 					})
 					})
@@ -380,14 +491,15 @@ define [
 
 
 		$scope.syncToCode = () ->
 		$scope.syncToCode = () ->
 			synctex
 			synctex
-				.syncToCode($scope.pdf.position, includeVisualOffset: true)
+				.syncToCode($scope.pdf.position, includeVisualOffset: true, fromPdfPosition: true)
 				.then (data) ->
 				.then (data) ->
 					{doc, line} = data
 					{doc, line} = data
 					ide.editorManager.openDoc(doc, gotoLine: line)
 					ide.editorManager.openDoc(doc, gotoLine: line)
 	]
 	]
 
 
-	App.controller "PdfLogEntryController", ["$scope", "ide", ($scope, ide) ->
+	App.controller "PdfLogEntryController", ["$scope", "ide", "event_tracking", ($scope, ide, event_tracking) ->
 		$scope.openInEditor = (entry) ->
 		$scope.openInEditor = (entry) ->
+			event_tracking.sendCountlyOnce "logs-jump-to-location-once"
 			entity = ide.fileTreeManager.findEntityByPath(entry.file)
 			entity = ide.fileTreeManager.findEntityByPath(entry.file)
 			return if !entity? or entity.type != "doc"
 			return if !entity? or entity.type != "doc"
 			if entry.line?
 			if entry.line?

+ 12 - 66
services/web/public/coffee/ide/pdfng/directives/pdfJs.coffee

@@ -1,23 +1,14 @@
 define [
 define [
 	"base"
 	"base"
 	"ide/pdfng/directives/pdfViewer"
 	"ide/pdfng/directives/pdfViewer"
-	"text!libs/pdfListView/TextLayer.css"
-	"text!libs/pdfListView/AnnotationsLayer.css"
-	"text!libs/pdfListView/HighlightsLayer.css"
 ], (
 ], (
 	App
 	App
 	pdfViewer
 	pdfViewer
-	textLayerCss
-	annotationsLayerCss
-	highlightsLayerCss
+
 ) ->
 ) ->
 	if PDFJS?
 	if PDFJS?
 		PDFJS.workerSrc = window.pdfJsWorkerPath
 		PDFJS.workerSrc = window.pdfJsWorkerPath
 
 
-	style = $("<style/>")
-	style.text(textLayerCss + "\n" + annotationsLayerCss + "\n" + highlightsLayerCss)
-	$("body").append(style)
-
 	App.directive "pdfng", ["$timeout", "localStorage", ($timeout, localStorage) ->
 	App.directive "pdfng", ["$timeout", "localStorage", ($timeout, localStorage) ->
 		return {
 		return {
 			scope: {
 			scope: {
@@ -27,15 +18,6 @@ define [
 					"dblClickCallback": "="
 					"dblClickCallback": "="
 			}
 			}
 			link: (scope, element, attrs) ->
 			link: (scope, element, attrs) ->
-				# pdfListView = new PDFListView element.find(".pdfjs-viewer")[0],
-				#		textLayerBuilder: TextLayerBuilder
-				#		annotationsLayerBuilder: AnnotationsLayerBuilder
-				#		highlightsLayerBuilder: HighlightsLayerBuilder
-				#		ondblclick: (e) -> onDoubleClick(e)
-				#		# logLevel: PDFListView.Logger.DEBUG
-				# pdfListView.listView.pageWidthOffset = 20
-				# pdfListView.listView.pageHeightOffset = 20
-
 				scope.loading = false
 				scope.loading = false
 				scope.pleaseJumpTo = null
 				scope.pleaseJumpTo = null
 				scope.scale = null
 				scope.scale = null
@@ -50,9 +32,11 @@ define [
 						scope.scale = { scaleMode: 'scale_mode_fit_width' }
 						scope.scale = { scaleMode: 'scale_mode_fit_width' }
 
 
 					if (position = localStorage("pdf.position.#{attrs.key}"))
 					if (position = localStorage("pdf.position.#{attrs.key}"))
-						scope.position = { page: +position.page, offset: { "top": +position.offset.top, "left": +position.offset.left } }
-
-					#scope.position = pdfListView.getPdfPosition(true)
+						scope.position =
+							page: +position.page,
+							offset:
+								"top": +position.offset.top
+								"left": +position.offset.left
 
 
 					scope.$on "$destroy", () =>
 					scope.$on "$destroy", () =>
 						localStorage "pdf.scale", scope.scale
 						localStorage "pdf.scale", scope.scale
@@ -78,56 +62,18 @@ define [
 				scope.$watch "pdfSrc", (url) ->
 				scope.$watch "pdfSrc", (url) ->
 					if url
 					if url
 						scope.loading = true
 						scope.loading = true
-						# console.log 'pdfSrc =', url
+						scope.loaded = false
+						scope.progress = 1
 						initializePosition()
 						initializePosition()
 						flashControls()
 						flashControls()
-						# pdfListView
-						#		.loadPdf(url, onProgress)
-						#		.then () ->
-						#			scope.$apply () ->
-						#				scope.loading = false
-						#				delete scope.progress
-						#				initializePosition()
-						#				flashControls()
-				
+
 				scope.$on "loaded", () ->
 				scope.$on "loaded", () ->
+					scope.loaded = true
 					scope.progress = 100
 					scope.progress = 100
-					scope.$apply()
 					$timeout () ->
 					$timeout () ->
 						scope.loading = false
 						scope.loading = false
 						delete scope.progress
 						delete scope.progress
-					, 250
-
-				#scope.$watch "highlights", (areas) ->
-					# console.log 'got HIGHLIGHTS in pdfJS', areas
-					
-					# return if !areas?
-					# highlights = for area in areas or []
-					# 	{
-					# 		page: area.page
-					# 		highlight:
-					# 			left: area.h
-					# 			top: area.v
-					# 			height: area.height
-					# 			width: area.width
-					# 	}
-
-					# if highlights.length > 0
-					# 	first = highlights[0]
-					# 	position = {
-					# 		page: first.page
-					# 		offset:
-					# 			left: first.highlight.left
-					# 			top: first.highlight.top - 80
-					# 	}
-					# 	console.log 'position is', position, 'in highlights'
-					# 	scope.pleaseJumpTo = position
-					# pdfListView.clearHighlights()
-					# pdfListView.setHighlights(highlights, true)
-
-					# setTimeout () =>
-					#		pdfListView.clearHighlights()
-					# , 1000
+					, 500
 
 
 				scope.fitToHeight = () ->
 				scope.fitToHeight = () ->
 					scale = angular.copy (scope.scale)
 					scale = angular.copy (scope.scale)
@@ -155,10 +101,10 @@ define [
 					for event in attrs.resizeOn.split(",")
 					for event in attrs.resizeOn.split(",")
 						scope.$on event, (e) ->
 						scope.$on event, (e) ->
 							#console.log 'got a resize event', event, e
 							#console.log 'got a resize event', event, e
-							#
 
 
 				scope.$on 'progress', (event, progress) ->
 				scope.$on 'progress', (event, progress) ->
 					scope.$apply () ->
 					scope.$apply () ->
+						return if scope.loaded
 						scope.progress = Math.floor(progress.loaded/progress.total*100)
 						scope.progress = Math.floor(progress.loaded/progress.total*100)
 						scope.progress = 100 if scope.progress > 100
 						scope.progress = 100 if scope.progress > 100
 						scope.progress = 0 if scope.progress < 0
 						scope.progress = 0 if scope.progress < 0

+ 2 - 2
services/web/public/coffee/ide/pdfng/directives/pdfPage.coffee

@@ -3,7 +3,7 @@ define [
 ], (App) ->
 ], (App) ->
 	# App = angular.module 'pdfPage', ['pdfHighlights']
 	# App = angular.module 'pdfPage', ['pdfHighlights']
 
 
-	App.directive 'pdfPage', ['$timeout', 'pdfHighlights', ($timeout, pdfHighlights) ->
+	App.directive 'pdfPage', ['$timeout', 'pdfHighlights', 'pdfSpinner', ($timeout, pdfHighlights, pdfSpinner) ->
 		{
 		{
 			require: '^pdfViewer',
 			require: '^pdfViewer',
 			template: '''
 			template: '''
@@ -82,7 +82,7 @@ define [
 					if scope.timeoutHandler
 					if scope.timeoutHandler
 						$timeout.cancel(scope.timeoutHandler)
 						$timeout.cancel(scope.timeoutHandler)
 						highlightsLayer.clearHighlights()
 						highlightsLayer.clearHighlights()
-						scope.timeoutHandler
+						scope.timeoutHandler = null
 
 
 					# console.log 'got highlight watch in pdfPage', scope.page
 					# console.log 'got highlight watch in pdfPage', scope.page
 					pageHighlights = (h for h in highlights when h.page == scope.page.pageNum)
 					pageHighlights = (h for h in highlights when h.page == scope.page.pageNum)

+ 126 - 78
services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee

@@ -8,11 +8,13 @@ define [
 		class PDFRenderer
 		class PDFRenderer
 			JOB_QUEUE_INTERVAL: 25
 			JOB_QUEUE_INTERVAL: 25
 			PAGE_LOAD_TIMEOUT: 60*1000
 			PAGE_LOAD_TIMEOUT: 60*1000
-			PAGE_RENDER_TIMEOUT: 60*1000
+			INDICATOR_DELAY1: 100  # time to delay before showing the indicator
+			INDICATOR_DELAY2: 250  # time until the indicator starts animating
 
 
 			constructor: (@url, @options) ->
 			constructor: (@url, @options) ->
 				# PDFJS.disableFontFace = true  # avoids repaints, uses worker more
 				# PDFJS.disableFontFace = true  # avoids repaints, uses worker more
-				# PDFJS.disableAutoFetch = true # enable this to prevent loading whole file
+				if @options.disableAutoFetch
+					PDFJS.disableAutoFetch = true # prevent loading whole file
 				# PDFJS.disableStream
 				# PDFJS.disableStream
 				# PDFJS.disableRange
 				# PDFJS.disableRange
 				@scale = @options.scale || 1
 				@scale = @options.scale || 1
@@ -28,15 +30,22 @@ define [
 				@errorCallback = @options.errorCallback
 				@errorCallback = @options.errorCallback
 				@pageSizeChangeCallback = @options.pageSizeChangeCallback
 				@pageSizeChangeCallback = @options.pageSizeChangeCallback
 				@pdfjs.promise.catch (exception) =>
 				@pdfjs.promise.catch (exception) =>
-					# console.log 'ERROR in get document', exception
+					# error getting document
 					@errorCallback(exception)
 					@errorCallback(exception)
 
 
 			resetState: () ->
 			resetState: () ->
-				@complete = []
-				@timeout = []
-				@pageLoad = []
-				@renderTask = []
 				@renderQueue = []
 				@renderQueue = []
+				clearTimeout @queueTimer if @queueTimer?
+				# clear any existing timers, render tasks
+				for timer in @spinTimer or []
+					clearTimeout timer
+				for page in @pageState or []
+					page?.loadTask?.cancel()
+					page?.renderTask?.cancel()
+				# initialise/reset the state
+				@pageState = []
+				@spinTimer = []     # timers for starting the spinners (to avoid jitter)
+				@spinTimerDone = [] # array of pages where the spinner has activated
 				@jobs = 0
 				@jobs = 0
 
 
 			getNumPages: () ->
 			getNumPages: () ->
@@ -45,7 +54,6 @@ define [
 
 
 			getPage: (pageNum) ->
 			getPage: (pageNum) ->
 				@document.then (pdfDocument) ->
 				@document.then (pdfDocument) ->
-					# console.log 'got pdf document, now getting Page', pageNum
 					pdfDocument.getPage(pageNum)
 					pdfDocument.getPage(pageNum)
 
 
 			getPdfViewport: (pageNum, scale) ->
 			getPdfViewport: (pageNum, scale) ->
@@ -80,14 +88,14 @@ define [
 				@resetState()
 				@resetState()
 
 
 			triggerRenderQueue: (interval = @JOB_QUEUE_INTERVAL) ->
 			triggerRenderQueue: (interval = @JOB_QUEUE_INTERVAL) ->
+				if @queueTimer?
+					clearTimeout @queueTimer
 				@queueTimer = setTimeout () =>
 				@queueTimer = setTimeout () =>
 					@queueTimer = null
 					@queueTimer = null
 					@processRenderQueue()
 					@processRenderQueue()
 				, interval
 				, interval
 
 
-			removeCompletedJob: (taskRef, pagenum) ->
-				# may need to clean up deferred object here
-				delete taskRef[pagenum]
+			removeCompletedJob: (pagenum) ->
 				@jobs = @jobs - 1
 				@jobs = @jobs - 1
 				@triggerRenderQueue(0)
 				@triggerRenderQueue(0)
 
 
@@ -109,69 +117,129 @@ define [
 				@renderQueue.push current
 				@renderQueue.push current
 				@processRenderQueue()
 				@processRenderQueue()
 
 
+			getPageDetails: (page) ->
+				return [page.element.canvas, page.pagenum]
+
+			# handle the loading indicators for each page
+
+			startIndicators: () ->
+				# make an array of the pages in the queue
+				@queuedPages = []
+				@queuedPages[page.pagenum] = true for page in @renderQueue
+				# clear any unfinished spinner timers on pages that aren't in the queue any more
+				for pagenum of @spinTimer when not @queuedPages[pagenum]
+					clearTimeout @spinTimer[pagenum]
+					delete @spinTimer[pagenum]
+				# add indicators for any new pages in the current queue
+				for page in @renderQueue when not @spinTimer[page.pagenum] and not @spinTimerDone[page.pagenum]
+					@startIndicator page
+
+			startIndicator: (page) ->
+				[canvas, pagenum] = @getPageDetails page
+				canvas.addClass('pdfng-loading')
+				@spinTimer[pagenum] = setTimeout () =>
+					for queuedPage in @renderQueue
+						if pagenum == queuedPage.pagenum
+							@spinner.add(canvas, {static:true})
+							@spinTimerDone[pagenum] = true
+							break
+					delete @spinTimer[pagenum]
+				, @INDICATOR_DELAY1
+
+			updateIndicator: (page) ->
+				[canvas, pagenum] = @getPageDetails page
+				# did the spinner insert itself already?
+				if @spinTimerDone[pagenum]
+					@spinTimer[pagenum] = setTimeout () =>
+						@spinner.start(canvas)
+						delete @spinTimer[pagenum]
+					, @INDICATOR_DELAY2
+				else
+					# stop the existing spin timer
+					clearTimeout @spinTimer[pagenum]
+					# start a new one which will also start spinning
+					@spinTimer[pagenum] = setTimeout () =>
+						@spinner.add(canvas, {static:true})
+						@spinTimerDone[pagenum] = true
+						@spinTimer[pagenum] = setTimeout () =>
+							@spinner.start(canvas)
+							delete @spinTimer[pagenum]
+						, @INDICATOR_DELAY2
+					, @INDICATOR_DELAY1
+
+			clearIndicator: (page) ->
+				[canvas, pagenum] = @getPageDetails page
+				@spinner.stop(canvas)
+				clearTimeout @spinTimer[pagenum]
+				delete @spinTimer[pagenum]
+				@spinTimerDone[pagenum] = true
+
+			# handle the queue of pages to be rendered
+
 			processRenderQueue: () ->
 			processRenderQueue: () ->
 				return if @shuttingDown
 				return if @shuttingDown
+				# mark all pages in the queue as loading
+				@startIndicators()
+				# bail out if there is already a render job running
 				return if @jobs > 0
 				return if @jobs > 0
-				current = @renderQueue.shift()
-				return unless current?
-				[element, pagenum] = [current.element, current.pagenum]
-				# if task is underway or complete, go to the next entry in the
-				# render queue
-				# console.log 'processing renderq', pagenum, @renderTask[pagenum], @complete[pagenum]
-				if @pageLoad[pagenum] or @renderTask[pagenum] or @complete[pagenum]
-					@processRenderQueue()
-					return
+				# take the first page in the queue
+				page = @renderQueue.shift()
+				# check if it is in action already
+				while page? and @pageState[page.pagenum]?
+					page = @renderQueue.shift()
+				return unless page?
+				[element, pagenum] = [page.element, page.pagenum]
 				@jobs = @jobs + 1
 				@jobs = @jobs + 1
 
 
-				element.canvas.addClass('pdfng-loading')
-				spinTimer = setTimeout () =>
-					@spinner.add(element.canvas)
-				, 100
-
-				completeRef = @complete
-				renderTaskRef = @renderTask
-				# console.log 'started page load', pagenum
+				# update the spinner to make it spinning (signifies loading has begun)
+				@updateIndicator page
 
 
 				timedOut = false
 				timedOut = false
-				timer = $timeout () =>
-					Raven?.captureMessage?('pdfng page load timed out after ' + @PAGE_LOAD_TIMEOUT + 'ms (1% sample)') if Math.random() < 0.01
-					# console.log 'page load timed out', pagenum
+				timer = $timeout () => # page load timed out
+					return if loadTask.cancelled # return from cancelled page load
+					Raven?.captureMessage?('pdfng page load timed out after ' + @PAGE_LOAD_TIMEOUT + 'ms')
 					timedOut = true
 					timedOut = true
-					clearTimeout(spinTimer)
-					@spinner.stop(element.canvas)
+					@clearIndicator page
 					# @jobs = @jobs - 1
 					# @jobs = @jobs - 1
 					# @triggerRenderQueue(0)
 					# @triggerRenderQueue(0)
 					@errorCallback?('timeout')
 					@errorCallback?('timeout')
 				, @PAGE_LOAD_TIMEOUT
 				, @PAGE_LOAD_TIMEOUT
 
 
-				@pageLoad[pagenum] = @getPage(pagenum)
+				loadTask = @getPage(pagenum)
 
 
-				@pageLoad[pagenum].then (pageObject) =>
-					# console.log 'in page load success', pagenum
+				loadTask.cancel = () ->
+					@cancelled = true
+
+				@pageState[pagenum] = pageState = { loadTask: loadTask }
+
+				loadTask.then (pageObject) =>
+					# page load success
 					$timeout.cancel(timer)
 					$timeout.cancel(timer)
-					clearTimeout(spinTimer)
-					@renderTask[pagenum] = @doRender element, pagenum, pageObject
-					@renderTask[pagenum].then () =>
-						# complete
-						# console.log 'render task success', pagenum
-						completeRef[pagenum] = true
-						@removeCompletedJob renderTaskRef, pagenum
+					return if loadTask.cancelled # return from cancelled page load
+					pageState.renderTask = @doRender element, pagenum, pageObject
+					pageState.renderTask.then () =>
+						# render task success
+						@clearIndicator page
+						pageState.complete = true
+						delete pageState.renderTask
+						@removeCompletedJob pagenum
 					, () =>
 					, () =>
-						# console.log 'render task failed', pagenum
-						# rejected
-						@removeCompletedJob renderTaskRef, pagenum
-				.catch (error) ->
-					# console.log 'in page load error', pagenum, 'timedOut=', timedOut
+						# render task failed
+						# could display an error icon
+						pageState.complete = false
+						delete pageState.renderTask
+						@removeCompletedJob pagenum
+				.catch (error) =>
+					# page load error
 					$timeout.cancel(timer)
 					$timeout.cancel(timer)
-					clearTimeout(spinTimer)
-					# console.log 'ERROR', error
+					@clearIndicator page
 
 
 			doRender: (element, pagenum, page) ->
 			doRender: (element, pagenum, page) ->
 				self = this
 				self = this
 				scale = @scale
 				scale = @scale
 
 
 				if (not scale?)
 				if (not scale?)
-					# console.log 'scale is undefined, returning'
+					# scale is undefined, returning
 					return
 					return
 
 
 				canvas = $('<canvas class="pdf-canvas pdfng-rendering"></canvas>')
 				canvas = $('<canvas class="pdf-canvas pdfng-rendering"></canvas>')
@@ -220,28 +288,15 @@ define [
 					navigateFn: @navigateFn
 					navigateFn: @navigateFn
 				})
 				})
 
 
-				element.canvas.replaceWith(canvas)
-
-				# console.log 'staring page render', pagenum
-
 				result = page.render {
 				result = page.render {
 					canvasContext: ctx
 					canvasContext: ctx
 					viewport: viewport
 					viewport: viewport
 					transform: [pixelRatio, 0, 0, pixelRatio, 0, 0]
 					transform: [pixelRatio, 0, 0, pixelRatio, 0, 0]
 				}
 				}
 
 
-				timedOut = false
-
-				timer = $timeout () =>
-					Raven?.captureMessage?('pdfng page render timed out after ' + @PAGE_RENDER_TIMEOUT + 'ms (1% sample)') if Math.random() < 0.01
-					# console.log 'page render timed out', pagenum
-					timedOut = true
-					result.cancel()
-				, @PAGE_RENDER_TIMEOUT
-
 				result.then () ->
 				result.then () ->
-					# console.log 'page rendered', pagenum
-					$timeout.cancel(timer)
+					# page render success
+					element.canvas.replaceWith(canvas)
 					canvas.removeClass('pdfng-rendering')
 					canvas.removeClass('pdfng-rendering')
 					page.getTextContent().then (textContent) ->
 					page.getTextContent().then (textContent) ->
 						textLayer.setTextContent textContent
 						textLayer.setTextContent textContent
@@ -252,24 +307,17 @@ define [
 					, (error) ->
 					, (error) ->
 						self.errorCallback?(error)
 						self.errorCallback?(error)
 				.catch (error) ->
 				.catch (error) ->
-					# console.log 'page render failed', pagenum, error
-					$timeout.cancel(timer)
-					if timedOut
-						# console.log 'calling ERROR callback - was timeout'
-						self.errorCallback?('timeout')
-					else if error != 'cancelled'
-						# console.log 'calling ERROR callback'
+					# page render failed
+					if error is 'cancelled'
+						return # do nothing when cancelled
+					else
 						self.errorCallback?(error)
 						self.errorCallback?(error)
 
 
 				return result
 				return result
 
 
 			destroy: () ->
 			destroy: () ->
-				# console.log 'in pdf renderer destroy', @renderQueue
 				@shuttingDown = true
 				@shuttingDown = true
-				clearTimeout @queueTimer if @queueTimer?
-				@renderQueue = []
-				for task in @renderTask
-					task.cancel() if task?
+				@resetState()
 				@pdfjs.then (document) ->
 				@pdfjs.then (document) ->
 					document.cleanup()
 					document.cleanup()
 					document.destroy()
 					document.destroy()

+ 5 - 4
services/web/public/coffee/ide/pdfng/directives/pdfSpinner.coffee

@@ -8,14 +8,15 @@ define [
 			constructor: () ->
 			constructor: () ->
 				# handler for spinners
 				# handler for spinners
 
 
-			add: (element) ->
-				h = element.height()
-				w = element.width()
+			add: (element, options) ->
 				size = 64
 				size = 64
-				spinner = $('<div class="pdfng-spinner" style="position: absolute; top: 50%; left:50%; transform: translateX(-50%) translateY(-50%);"><i class="fa fa-spinner fa-spin" style="color: #999"></i></div>')
+				spinner = $('<div class="pdfng-spinner" style="position: absolute; top: 50%; left:50%; transform: translateX(-50%) translateY(-50%);"><i class="fa fa-spinner' + (if options?.static then '' else ' fa-spin') + '" style="color: #999"></i></div>')
 				spinner.css({'font-size' : size + 'px'})
 				spinner.css({'font-size' : size + 'px'})
 				element.append(spinner)
 				element.append(spinner)
 
 
+			start: (element) ->
+				element.find('.fa-spinner').addClass('fa-spin')
+
 			stop: (element) ->
 			stop: (element) ->
 				element.find('.fa-spinner').removeClass('fa-spin')
 				element.find('.fa-spinner').removeClass('fa-spin')
 
 

+ 41 - 20
services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee

@@ -27,8 +27,15 @@ define [
 			$scope.document.destroy() if $scope.document?
 			$scope.document.destroy() if $scope.document?
 			$scope.loadCount = if $scope.loadCount? then $scope.loadCount + 1 else 1
 			$scope.loadCount = if $scope.loadCount? then $scope.loadCount + 1 else 1
 			# TODO need a proper url manipulation library to add to query string
 			# TODO need a proper url manipulation library to add to query string
-			$scope.document = new PDFRenderer($scope.pdfSrc + '&pdfng=true' , {
+			url = $scope.pdfSrc
+			# add 'pdfng=true' to show that we are using the angular pdfjs viewer
+			queryStringExists = url.match(/\?/)
+			url = url + (if not queryStringExists then '?' else '&') + 'pdfng=true'
+			# for isolated compiles, load the pdf on-demand because nobody will overwrite it
+			onDemandLoading = true
+			$scope.document = new PDFRenderer(url, {
 				scale: 1,
 				scale: 1,
+				disableAutoFetch: if onDemandLoading then true else undefined
 				navigateFn: (ref) ->
 				navigateFn: (ref) ->
 					# this function captures clicks on the annotation links
 					# this function captures clicks on the annotation links
 					$scope.navigateTo = ref
 					$scope.navigateTo = ref
@@ -38,7 +45,7 @@ define [
 				loadedCallback: () ->
 				loadedCallback: () ->
 					$scope.$emit 'loaded'
 					$scope.$emit 'loaded'
 				errorCallback: (error) ->
 				errorCallback: (error) ->
-					Raven?.captureMessage?('pdfng error ' + error + ' (1% sample)') if Math.random() < 0.01
+					Raven?.captureMessage?('pdfng error ' + error)
 					$scope.$emit 'pdf:error', error
 					$scope.$emit 'pdf:error', error
 				pageSizeChangeCallback: (pageNum, deltaH) ->
 				pageSizeChangeCallback: (pageNum, deltaH) ->
 					$scope.$broadcast 'pdf:page:size-change', pageNum, deltaH
 					$scope.$broadcast 'pdf:page:size-change', pageNum, deltaH
@@ -57,6 +64,7 @@ define [
 						result.pdfViewport.width
 						result.pdfViewport.width
 					]
 					]
 					# console.log 'resolved q.all, page size is', result
 					# console.log 'resolved q.all, page size is', result
+					$scope.$emit 'loaded'
 					$scope.numPages = result.numPages
 					$scope.numPages = result.numPages
 				.catch (error) ->
 				.catch (error) ->
 					$scope.$emit 'pdf:error', error
 					$scope.$emit 'pdf:error', error
@@ -187,7 +195,8 @@ define [
 			# console.log 'converted to offset = ', pdfOffset
 			# console.log 'converted to offset = ', pdfOffset
 			newPosition = {
 			newPosition = {
 				"page": topPageIdx,
 				"page": topPageIdx,
-				"offset" : { "top" : pdfOffset[1], "left": 0	}
+				"offset" : { "top" : pdfOffset[1], "left": 0}
+				"pageSize": { "height": viewport.viewBox[3], "width": viewport.viewBox[2] }
 			}
 			}
 			return newPosition
 			return newPosition
 
 
@@ -208,6 +217,8 @@ define [
 			return $scope.document.getPdfViewport(page.pageNum).then (viewport) ->
 			return $scope.document.getPdfViewport(page.pageNum).then (viewport) ->
 				page.viewport = viewport
 				page.viewport = viewport
 				pageOffset = viewport.convertToViewportPoint(offset.left, offset.top)
 				pageOffset = viewport.convertToViewportPoint(offset.left, offset.top)
+				# if the passed-in position doesn't have the page height/width add them now
+				position.pageSize ?= {"height": viewport.viewBox[3], "width": viewport.viewBox[2]}
 				# console.log 'addition offset =', pageOffset
 				# console.log 'addition offset =', pageOffset
 				# console.log 'total', pageTop + pageOffset[1]
 				# console.log 'total', pageTop + pageOffset[1]
 				Math.round(pageTop + pageOffset[1] + currentScroll) ## 10 is margin
 				Math.round(pageTop + pageOffset[1] + currentScroll) ## 10 is margin
@@ -246,8 +257,8 @@ define [
 					# console.log 'layoutReady was resolved'
 					# console.log 'layoutReady was resolved'
 
 
 				renderVisiblePages = () ->
 				renderVisiblePages = () ->
-					pages = getVisiblePages()
-					# pages = getExtraPages visiblePages
+					visiblePages = getVisiblePages()
+					pages = getExtraPages visiblePages
 					scope.document.renderPages(pages)
 					scope.document.renderPages(pages)
 
 
 				getVisiblePages = () ->
 				getVisiblePages = () ->
@@ -264,20 +275,21 @@ define [
 
 
 				getExtraPages = (visiblePages) ->
 				getExtraPages = (visiblePages) ->
 					extra = []
 					extra = []
-					firstVisiblePage = visiblePages[0].pageNum
-					firstVisiblePageIdx = firstVisiblePage - 1
-					len = visiblePages.length
-					lastVisiblePage = visiblePages[len-1].pageNum
-					lastVisiblePageIdx = lastVisiblePage - 1
-					# first page after
-					if lastVisiblePageIdx + 1 < scope.pages.length
-						extra.push scope.pages[lastVisiblePageIdx + 1]
-					# page before
-					if firstVisiblePageIdx > 0
-						extra.push scope.pages[firstVisiblePageIdx - 1]
-					# second page after
-					if lastVisiblePageIdx + 2 < scope.pages.length
-						extra.push scope.pages[lastVisiblePageIdx + 2]
+					if visiblePages.length > 0
+						firstVisiblePage = visiblePages[0].pageNum
+						firstVisiblePageIdx = firstVisiblePage - 1
+						len = visiblePages.length
+						lastVisiblePage = visiblePages[len-1].pageNum
+						lastVisiblePageIdx = lastVisiblePage - 1
+						# first page after
+						if lastVisiblePageIdx + 1 < scope.pages.length
+							extra.push scope.pages[lastVisiblePageIdx + 1]
+						# page before
+						if firstVisiblePageIdx > 0
+							extra.push scope.pages[firstVisiblePageIdx - 1]
+						# second page after
+						if lastVisiblePageIdx + 2 < scope.pages.length
+							extra.push scope.pages[lastVisiblePageIdx + 2]
 					return visiblePages.concat extra
 					return visiblePages.concat extra
 
 
 				rescaleTimer = null
 				rescaleTimer = null
@@ -513,8 +525,17 @@ define [
 
 
 					first = highlights[0]
 					first = highlights[0]
 
 
-					pageNum = scope.pages[first.page].pageNum
+					# switching between split and full pdf views can cause
+					# highlights to appear before rendering
+					if !scope.pages
+						return # ignore highlight scroll if still rendering
+
+					pageNum = scope.pages[first.page]?.pageNum
+
+					if !pageNum?
+						return # ignore highlight scroll if page not found
 
 
+					# use a visual offset of 72pt to match the offset in PdfController syncToCode
 					scope.document.getPdfViewport(pageNum).then (viewport) ->
 					scope.document.getPdfViewport(pageNum).then (viewport) ->
 						position = {
 						position = {
 							page: first.page
 							page: first.page

+ 25 - 1
services/web/public/coffee/ide/settings/services/settings.coffee

@@ -1,19 +1,43 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.factory "settings", ["ide", (ide) ->
+	App.factory "settings", ["ide", "event_tracking", (ide, event_tracking) ->
 		return {
 		return {
 			saveSettings: (data) ->
 			saveSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "setting-changed", { changedSetting, changedSettingVal }
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				data._csrf = window.csrfToken
 				ide.$http.post "/user/settings", data
 				ide.$http.post "/user/settings", data
 
 
+
 			saveProjectSettings: (data) ->
 			saveProjectSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "project-setting-changed", { changedSetting, changedSettingVal}
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				data._csrf = window.csrfToken
 				ide.$http.post "/project/#{ide.project_id}/settings", data
 				ide.$http.post "/project/#{ide.project_id}/settings", data
+
 				
 				
 			saveProjectAdminSettings: (data) ->
 			saveProjectAdminSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "project-admin-setting-changed", { changedSetting, changedSettingVal }
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				data._csrf = window.csrfToken
 				ide.$http.post "/project/#{ide.project_id}/settings/admin", data
 				ide.$http.post "/project/#{ide.project_id}/settings/admin", data
+
 				
 				
 		}
 		}
 	]
 	]

+ 3 - 1
services/web/public/coffee/ide/share/controllers/ShareController.coffee

@@ -1,8 +1,10 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
+	App.controller "ShareController", ["$scope", "$modal", "event_tracking", ($scope, $modal, event_tracking) ->
 		$scope.openShareProjectModal = () ->
 		$scope.openShareProjectModal = () ->
+			event_tracking.sendCountlyOnce "ide-open-share-modal-once"
+
 			$modal.open(
 			$modal.open(
 				templateUrl: "shareProjectModalTemplate"
 				templateUrl: "shareProjectModalTemplate"
 				controller:  "ShareProjectModalController"
 				controller:  "ShareProjectModalController"

+ 2 - 2
services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee

@@ -4,7 +4,7 @@ define [
 	App.controller 'WordCountModalController', ($scope, $modalInstance, ide, $http) ->
 	App.controller 'WordCountModalController', ($scope, $modalInstance, ide, $http) ->
 		$scope.status = 
 		$scope.status = 
 			loading:true
 			loading:true
-			
+
 		opts =
 		opts =
 			url:"/project/#{ide.project_id}/wordcount"
 			url:"/project/#{ide.project_id}/wordcount"
 			method:"GET"
 			method:"GET"
@@ -18,4 +18,4 @@ define [
 				$scope.status.error = true
 				$scope.status.error = true
 
 
 		$scope.cancel = () ->
 		$scope.cancel = () ->
-			$modalInstance.dismiss('cancel')
+			$modalInstance.dismiss('cancel')

+ 1 - 0
services/web/public/coffee/libs.coffee

@@ -16,3 +16,4 @@ define [
 	"libs/angular-sixpack"
 	"libs/angular-sixpack"
 	"libs/ng-tags-input-3.0.0"
 	"libs/ng-tags-input-3.0.0"
 ], () ->
 ], () ->
+

+ 3 - 1
services/web/public/coffee/main/account-upgrade.coffee

@@ -1,11 +1,13 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.controller "FreeTrialModalController", ($scope, abTestManager, sixpack)->
+	App.controller "FreeTrialModalController", ($scope, abTestManager, sixpack, event_tracking)->
 
 
 		$scope.buttonClass = "btn-primary"
 		$scope.buttonClass = "btn-primary"
 
 
 		$scope.startFreeTrial = (source, couponCode) ->
 		$scope.startFreeTrial = (source, couponCode) ->
+			event_tracking.sendCountly "subscription-start-trial", { source }
+
 			w = window.open()
 			w = window.open()
 			sixpack.convert "track-changes-discount", ->
 			sixpack.convert "track-changes-discount", ->
 				sixpack.participate 'in-editor-free-trial-plan', ['student', 'collaborator'], (planName, rawResponse)->
 				sixpack.participate 'in-editor-free-trial-plan', ['student', 'collaborator'], (planName, rawResponse)->

+ 29 - 3
services/web/public/coffee/main/contact-us.coffee

@@ -1,9 +1,8 @@
 define [
 define [
 	"base"
 	"base"
 	"libs/platform"
 	"libs/platform"
+	"services/algolia-search"
 ], (App, platform) ->
 ], (App, platform) ->
-
-
 	App.controller 'ContactModal', ($scope, $modal) ->
 	App.controller 'ContactModal', ($scope, $modal) ->
 		$scope.contactUsModal = () ->
 		$scope.contactUsModal = () ->
 			modalInstance = $modal.open(
 			modalInstance = $modal.open(
@@ -11,10 +10,25 @@ define [
 				controller: "SupportModalController"
 				controller: "SupportModalController"
 			)
 			)
 
 
-	App.controller 'SupportModalController', ($scope, $modalInstance) ->
+	App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) ->
 		$scope.form = {}
 		$scope.form = {}
 		$scope.sent = false
 		$scope.sent = false
 		$scope.sending = false
 		$scope.sending = false
+		$scope.suggestions = [];
+
+		_handleSearchResults = (success, results) ->
+			suggestions = for hit in results.hits
+				page_underscored = hit.pageName.replace(/\s/g,'_')
+
+				suggestion = 
+					url :"/learn/kb/#{page_underscored}"
+					name : hit._highlightResult.pageName.value
+
+			event_tracking.sendCountly "contact-form-suggestions-shown" if results.hits.length
+
+			$scope.$applyAsync () -> 
+				$scope.suggestions = suggestions
+
 		$scope.contactUs = ->
 		$scope.contactUs = ->
 			if !$scope.form.email?
 			if !$scope.form.email?
 				console.log "email not set"
 				console.log "email not set"
@@ -36,6 +50,18 @@ define [
 				$scope.sent = true
 				$scope.sent = true
 				$scope.$apply()
 				$scope.$apply()
 
 
+		$scope.$watch "form.subject", (newVal, oldVal) ->
+			if newVal and newVal != oldVal and newVal.length > 3
+				algoliaSearch.searchKB newVal, _handleSearchResults, { 
+					hitsPerPage: 3
+					typoTolerance: 'strict'
+				}
+			else
+				$scope.suggestions = [];
+
+		$scope.clickSuggestionLink = (url) ->
+			event_tracking.sendCountly "contact-form-suggestions-clicked", { url }
+
 		$scope.close = () ->
 		$scope.close = () ->
 			$modalInstance.close()
 			$modalInstance.close()
 
 

+ 46 - 2
services/web/public/coffee/main/event.coffee

@@ -1,17 +1,61 @@
 define [
 define [
 	"base"
 	"base"
+	"modules/localStorage"
 ], (App) ->
 ], (App) ->
+	CACHE_KEY = "countlyEvents"
+
+	App.factory "event_tracking", (localStorage) ->
+		_getEventCache = () -> 
+			eventCache = localStorage CACHE_KEY
+
+			# Initialize as an empy object if the event cache is still empty.
+			if !eventCache?
+				eventCache = {}
+				localStorage CACHE_KEY, eventCache 
+
+			return eventCache
+
+		_eventInCache = (key) ->
+			curCache = _getEventCache()
+			curCache[key] || false
+
+		_addEventToCache = (key) ->
+			curCache = _getEventCache()
+			curCache[key] = true
+
+			localStorage CACHE_KEY, curCache
 
 
-	App.factory "event_tracking", ->
 		return {
 		return {
 			send: (category, action, label, value)->
 			send: (category, action, label, value)->
 				ga('send', 'event', category, action, label, value)
 				ga('send', 'event', category, action, label, value)
+
+			sendCountly: (key, segmentation) ->
+				eventData = { key }
+				eventData.segmentation = segmentation if segmentation?				
+				Countly?.q.push([ "add_event", eventData ])
+
+			sendCountlySampled: (key, segmentation) ->
+				@sendCountly key, segmentation if Math.random() < .01 
+
+			sendCountlyOnce: (key, segmentation) ->
+				if ! _eventInCache(key)
+					_addEventToCache(key)
+					@sendCountly key, segmentation
 		}
 		}
 
 
+	# App.directive "countlyTrack", () ->
+	# 	return {
+	# 		restrict: "A"
+	# 		scope: false,
+	# 		link: (scope, el, attrs) ->
+	# 			eventKey  = attrs.countlyTrack
+	# 			if (eventKey?)
+	# 				el.on "click", () ->
+	# 					console.log eventKey
+	# 	}
 
 
 	#header
 	#header
 	$('.navbar a').on "click", (e)->
 	$('.navbar a').on "click", (e)->
 		href = $(e.target).attr("href")
 		href = $(e.target).attr("href")
 		if href?
 		if href?
 			ga('send', 'event', 'navigation', 'top menu bar', href)
 			ga('send', 'event', 'navigation', 'top menu bar', href)
-

+ 3 - 9
services/web/public/coffee/main/learn.coffee

@@ -1,15 +1,9 @@
 define [
 define [
 	"base"
 	"base"
+	"services/algolia-search"
 ], (App) ->
 ], (App) ->
-	
-	App.factory "algoliawiki", ->
-		if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
-			client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
-			index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
-			return index
 
 
-	App.controller "SearchWikiController", ($scope, algoliawiki, _, $modal) ->
-		algolia = algoliawiki
+	App.controller "SearchWikiController", ($scope, algoliaSearch, _, $modal) ->
 		$scope.hits = []
 		$scope.hits = []
 
 
 		$scope.clearSearchText = ->
 		$scope.clearSearchText = ->
@@ -54,7 +48,7 @@ define [
 				updateHits []
 				updateHits []
 				return
 				return
 				
 				
-			algolia.search query, (err, response)->
+			algoliaSearch.searchWiki query, (err, response)->
 				if response.hits.length == 0
 				if response.hits.length == 0
 					updateHits []
 					updateHits []
 				else
 				else

+ 19 - 1
services/web/public/coffee/main/new-subscription.coffee

@@ -2,7 +2,7 @@ define [
 	"base"
 	"base"
 ], (App)->
 ], (App)->
 
 
-	App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack)->
+	App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking)->
 		throw new Error("Recurly API Library Missing.")  if typeof recurly is "undefined"
 		throw new Error("Recurly API Library Missing.")  if typeof recurly is "undefined"
 	
 	
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
@@ -51,6 +51,8 @@ define [
 			.done()
 			.done()
 
 
 		pricing.on "change", =>
 		pricing.on "change", =>
+			event_tracking.sendCountly "subscription-form", { plan : pricing.items.plan.code }
+
 			$scope.planName = pricing.items.plan.name
 			$scope.planName = pricing.items.plan.name
 			$scope.price = pricing.price
 			$scope.price = pricing.price
 			$scope.trialLength = pricing.items.plan.trial?.length
 			$scope.trialLength = pricing.items.plan.trial?.length
@@ -115,9 +117,25 @@ define [
 						currencyCode:pricing.items.currency
 						currencyCode:pricing.items.currency
 						plan_code:pricing.items.plan.code
 						plan_code:pricing.items.plan.code
 						coupon_code:pricing.items?.coupon?.code || ""
 						coupon_code:pricing.items?.coupon?.code || ""
+						isPaypal: $scope.paymentMethod == 'paypal'
+						address:
+							address1:    $scope.data.address1
+							address2:    $scope.data.address2
+							country:     $scope.data.country
+							state:       $scope.data.state
+							postal_code: $scope.data.postal_code
+				
+				event_tracking.sendCountly "subscription-form-submitted", { 
+					currencyCode	: postData.subscriptionDetails.currencyCode,
+					plan_code		: postData.subscriptionDetails.plan_code,
+					coupon_code		: postData.subscriptionDetails.coupon_code,
+					isPaypal		: postData.subscriptionDetails.isPaypal
+				}
+
 				$http.post("/user/subscription/create", postData)
 				$http.post("/user/subscription/create", postData)
 					.success (data, status, headers)->
 					.success (data, status, headers)->
 						sixpack.convert "in-editor-free-trial-plan", pricing.items.plan.code, (err)->
 						sixpack.convert "in-editor-free-trial-plan", pricing.items.plan.code, (err)->
+							event_tracking.sendCountly "subscription-submission-success"
 							window.location.href = "/user/subscription/thank-you"
 							window.location.href = "/user/subscription/thank-you"
 					.error (data, status, headers)->
 					.error (data, status, headers)->
 						$scope.processing = false
 						$scope.processing = false

+ 3 - 4
services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee

@@ -3,8 +3,7 @@ define [
 ], (App) ->
 ], (App) ->
 
 
 	App.controller 'LeftHandMenuPromoController', ($scope) ->
 	App.controller 'LeftHandMenuPromoController', ($scope) ->
-		
-		$scope.showDatajoy = Math.random() < 0.5
+
 		$scope.hasProjects = window.data.projects.length > 0
 		$scope.hasProjects = window.data.projects.length > 0
-		$scope.userHasSubscription = window.userHasSubscription
-		$scope.randomView = _.shuffle(["default", "dropbox", "github"])[0]
+		$scope.userHasNoSubscription = window.userHasNoSubscription
+		$scope.randomView = _.shuffle(["default", "dropbox", "github"])[0]

+ 3 - 9
services/web/public/coffee/main/templates.coffee

@@ -1,15 +1,9 @@
 define [
 define [
 	"base"
 	"base"
+	"services/algolia-search"
 ], (App) ->
 ], (App) ->
-	
-	App.factory "algoliawiki", ->
-		if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
-			client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
-			index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
-			return index
 
 
-	App.controller "SearchWikiController", ($scope, algoliawiki, _) ->
-		algolia = algoliawiki
+	App.controller "SearchWikiController", ($scope, algoliaSearch, _) ->
 		$scope.hits = []
 		$scope.hits = []
 
 
 		$scope.clearSearchText = ->
 		$scope.clearSearchText = ->
@@ -54,7 +48,7 @@ define [
 				updateHits []
 				updateHits []
 				return
 				return
 
 
-			algolia.search query, (err, response)->
+			algoliaSearch.searchWiki query, (err, response)->
 				if response.hits.length == 0
 				if response.hits.length == 0
 					updateHits []
 					updateHits []
 				else
 				else

+ 14 - 0
services/web/public/coffee/services/algolia-search.coffee

@@ -0,0 +1,14 @@
+define [
+	"base"
+], (App) ->
+	App.factory "algoliaSearch", ->
+		if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
+			client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
+			wikiIdx = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
+			kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
+
+		service =
+			searchWiki: wikiIdx.search.bind(wikiIdx)
+			searchKB: kbIdx.search.bind(kbIdx)
+		
+		return service

+ 58 - 0
services/web/public/coffee/services/log-hints-feedback.coffee

@@ -0,0 +1,58 @@
+define [
+	"base"
+], (App) ->
+	App.factory "logHintsFeedback", ($http, $q) ->
+		hintsFeedbackFormAPIHash = "rl4xgvr1v5t64a"
+		idStampVal = "OPkEWEFHUFAm7hKlraQMhiOXQabafWo8NipRvLT397w="
+		hintFieldAPIId = "3"
+		reasonFieldAPIId = "1"
+		reasonOtherFieldAPIId = "1_other_other"
+		submitEndpoint = "https://sharelatex.wufoo.eu/forms/#{ hintsFeedbackFormAPIHash }/#public"
+
+		feedbackOpts =
+			DIDNT_UNDERSTAND: "didnt_understand"
+			NOT_APPLICABLE: "not_applicable"
+			INCORRECT: "incorrect"
+			OTHER: "other"
+
+		createRequest = (hintId, feedbackOpt, feedbackOtherVal = "") ->
+			formData = new FormData()
+
+			formData.append "Field#{ hintFieldAPIId }", hintId
+			formData.append "Field#{ reasonFieldAPIId }", feedbackOpt
+			formData.append "idstamp", idStampVal
+
+			# Allow user to specify "other" without any extra details; an empty string 
+			# would trigger an error submitting.
+			if feedbackOpt == feedbackOpts.OTHER and feedbackOtherVal == ""
+				formData.append "Field#{ reasonOtherFieldAPIId }", "#{ feedbackOpts.OTHER } empty"
+			else
+				formData.append "Field#{ reasonOtherFieldAPIId }", feedbackOtherVal
+
+			req =
+				method: 'POST'
+				url: submitEndpoint
+				# This will effectively disable Angular's default serialization mechanisms,
+				# forcing the XHR to be done with whatever data we provide (in this case,
+				# form data). Without this, Angular will forcefully try to serialize data
+				# to JSON.
+				transformRequest: angular.identity
+				data: formData
+				headers : 
+					# This will tell Angular to use the browser-provided value, which is
+					# computed according to the data being sent (in this case, multipart
+					# form + browser-specific multipart boundary). Without this, Angular
+					# will set JSON.
+					"Content-Type": undefined
+
+			return req
+
+		submitFeedback = (hintId, feedbackOpt, feedbackOtherVal = "") ->
+			submitRequest = createRequest hintId, feedbackOpt, feedbackOtherVal
+			$http(submitRequest)
+
+		service =
+			feedbackOpts: feedbackOpts
+			submitFeedback: submitFeedback
+		
+		return service

BIN
services/web/public/img/about/brian_gough.jpg


BIN
services/web/public/img/about/geri.jpg


BIN
services/web/public/img/about/henry_oswald.jpg


BIN
services/web/public/img/about/james_allen.jpg


BIN
services/web/public/img/about/kiri_channon.jpg


BIN
services/web/public/img/about/michael.jpg


BIN
services/web/public/img/about/paulo_reis.jpg


BIN
services/web/public/img/about/shane_kilkelly.jpg


BIN
services/web/public/img/crests/cambridge.png


BIN
services/web/public/img/crests/durham.png


BIN
services/web/public/img/crests/icl.png


BIN
services/web/public/img/crests/liverpool.jpg


BIN
services/web/public/img/crests/mit.gif


BIN
services/web/public/img/crests/nasa.png


Неке датотеке нису приказане због велике количине промена